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,72 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const INSECURE_COMMAND_PATTERNS = [
4
+ {
5
+ pattern: /\b(?:curl|wget)\b[\s\S]*?\|\s*(?:sh|bash)\b/iu,
6
+ description: "remote download piped directly into a shell",
7
+ },
8
+ {
9
+ pattern: /\bbash\s*<\s*\(\s*curl\b/iu,
10
+ description: "process substitution executes downloaded shell content",
11
+ },
12
+ {
13
+ pattern: /\bsh\s*<\s*\(\s*curl\b/iu,
14
+ description: "process substitution executes downloaded shell content",
15
+ },
16
+ ];
17
+ function findInsecureCommand(run) {
18
+ if (typeof run !== "string") {
19
+ return null;
20
+ }
21
+ for (const entry of INSECURE_COMMAND_PATTERNS) {
22
+ if (entry.pattern.test(run)) {
23
+ return { match: run, description: entry.description };
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ export function detectWorkflowInsecureCommands(input) {
29
+ if (!isGitHubWorkflowPath(input.filePath)) {
30
+ return [];
31
+ }
32
+ const facts = extractWorkflowFacts(input.parsed);
33
+ if (!facts) {
34
+ return [];
35
+ }
36
+ const findings = [];
37
+ facts.jobs.forEach((job, jobIndex) => {
38
+ job.steps.forEach((step, stepIndex) => {
39
+ const insecure = findInsecureCommand(step.run);
40
+ if (!insecure) {
41
+ return;
42
+ }
43
+ const evidence = buildFindingEvidence({
44
+ textContent: input.textContent,
45
+ searchTerms: [insecure.match],
46
+ fallbackValue: insecure.match,
47
+ });
48
+ findings.push({
49
+ rule_id: "workflow-insecure-commands",
50
+ finding_id: `WORKFLOW_INSECURE_COMMANDS-${input.filePath}-${jobIndex}-${stepIndex}`,
51
+ severity: "HIGH",
52
+ category: "COMMAND_EXEC",
53
+ layer: "L2",
54
+ file_path: input.filePath,
55
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
56
+ description: `Workflow uses ${insecure.description}`,
57
+ affected_tools: ["github-actions"],
58
+ cve: null,
59
+ owasp: ["ASI02"],
60
+ cwe: "CWE-78",
61
+ confidence: "HIGH",
62
+ fixable: false,
63
+ remediation_actions: [
64
+ "Download artifacts separately, verify integrity, and run them only after review",
65
+ ],
66
+ evidence: evidence?.evidence ?? null,
67
+ suppressed: false,
68
+ });
69
+ });
70
+ });
71
+ return findings;
72
+ }
@@ -0,0 +1,8 @@
1
+ import type { RuntimeMode } from "../../config.js";
2
+ import type { Finding } from "../../types/finding.js";
3
+ export interface WorkflowKnownVulnActionInput {
4
+ filePath: string;
5
+ parsed: unknown;
6
+ runtimeMode?: RuntimeMode;
7
+ }
8
+ export declare function detectWorkflowKnownVulnAction(input: WorkflowKnownVulnActionInput): Finding[];
@@ -0,0 +1,70 @@
1
+ import { loadKnownVulnerableActions } from "../advisories/gha-advisory-client.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ function parseRepositoryUses(value) {
4
+ const trimmed = value.trim();
5
+ if (trimmed.startsWith("./") || trimmed.startsWith("docker://")) {
6
+ return null;
7
+ }
8
+ const atIndex = trimmed.lastIndexOf("@");
9
+ if (atIndex < 0) {
10
+ return null;
11
+ }
12
+ const slug = trimmed.slice(0, atIndex).toLowerCase();
13
+ const ref = trimmed.slice(atIndex + 1).toLowerCase();
14
+ if (!slug.includes("/") || ref.length === 0) {
15
+ return null;
16
+ }
17
+ return { slug, ref };
18
+ }
19
+ export function detectWorkflowKnownVulnAction(input) {
20
+ if (input.runtimeMode !== "online") {
21
+ return [];
22
+ }
23
+ if (!isGitHubWorkflowPath(input.filePath)) {
24
+ return [];
25
+ }
26
+ const facts = extractWorkflowFacts(input.parsed);
27
+ if (!facts) {
28
+ return [];
29
+ }
30
+ const advisories = loadKnownVulnerableActions({ runtimeMode: input.runtimeMode }).advisories;
31
+ const findings = [];
32
+ facts.jobs.forEach((job, jobIndex) => {
33
+ job.steps.forEach((step, stepIndex) => {
34
+ const uses = step.uses;
35
+ if (!uses) {
36
+ return;
37
+ }
38
+ const parsedUses = parseRepositoryUses(uses);
39
+ if (!parsedUses) {
40
+ return;
41
+ }
42
+ const vulnerableVersions = advisories[parsedUses.slug];
43
+ if (!vulnerableVersions || !vulnerableVersions.includes(parsedUses.ref)) {
44
+ return;
45
+ }
46
+ findings.push({
47
+ rule_id: "workflow-known-vuln-action",
48
+ finding_id: `WORKFLOW_KNOWN_VULN_ACTION-${input.filePath}-${jobIndex}-${stepIndex}`,
49
+ severity: "HIGH",
50
+ category: "CI_VULNERABLE_ACTION",
51
+ layer: "L2",
52
+ file_path: input.filePath,
53
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].uses` },
54
+ description: `Action ${parsedUses.slug}@${parsedUses.ref} is listed in known vulnerable references`,
55
+ affected_tools: ["github-actions"],
56
+ cve: null,
57
+ owasp: ["ASI02"],
58
+ cwe: "CWE-937",
59
+ confidence: "HIGH",
60
+ fixable: false,
61
+ remediation_actions: [
62
+ "Upgrade to a non-vulnerable action release and pin to a reviewed commit SHA",
63
+ ],
64
+ evidence: uses,
65
+ suppressed: false,
66
+ });
67
+ });
68
+ });
69
+ return findings;
70
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowMisfeatureInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowMisfeature(input: WorkflowMisfeatureInput): Finding[];
@@ -0,0 +1,101 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const SECURITY_TOKENS = [
4
+ "codeql",
5
+ "security",
6
+ "sast",
7
+ "secret",
8
+ "dependency review",
9
+ "npm audit",
10
+ "trivy",
11
+ ];
12
+ function asRecord(value) {
13
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
14
+ return null;
15
+ }
16
+ return value;
17
+ }
18
+ function isContinueOnErrorEnabled(value) {
19
+ if (value === true) {
20
+ return true;
21
+ }
22
+ return typeof value === "string" && value.trim().toLowerCase() === "true";
23
+ }
24
+ function isSecurityStep(candidate) {
25
+ const combined = [candidate.name, candidate.run, candidate.uses]
26
+ .filter((value) => typeof value === "string")
27
+ .join(" ")
28
+ .toLowerCase();
29
+ return SECURITY_TOKENS.some((token) => combined.includes(token));
30
+ }
31
+ function gatherCandidates(parsed) {
32
+ const root = asRecord(parsed);
33
+ const jobs = asRecord(root?.jobs);
34
+ if (!jobs) {
35
+ return [];
36
+ }
37
+ const candidates = [];
38
+ for (const [jobId, jobValue] of Object.entries(jobs)) {
39
+ const job = asRecord(jobValue);
40
+ const steps = Array.isArray(job?.steps) ? job.steps : [];
41
+ steps.forEach((stepValue, stepIndex) => {
42
+ const step = asRecord(stepValue);
43
+ if (!step) {
44
+ return;
45
+ }
46
+ candidates.push({
47
+ jobId,
48
+ stepIndex,
49
+ name: typeof step.name === "string" ? step.name : undefined,
50
+ run: typeof step.run === "string" ? step.run : undefined,
51
+ uses: typeof step.uses === "string" ? step.uses : undefined,
52
+ continueOnError: isContinueOnErrorEnabled(step["continue-on-error"]),
53
+ });
54
+ });
55
+ }
56
+ return candidates;
57
+ }
58
+ export function detectWorkflowMisfeature(input) {
59
+ if (!isGitHubWorkflowPath(input.filePath)) {
60
+ return [];
61
+ }
62
+ const facts = extractWorkflowFacts(input.parsed);
63
+ if (!facts) {
64
+ return [];
65
+ }
66
+ const findings = [];
67
+ for (const candidate of gatherCandidates(input.parsed)) {
68
+ if (!candidate.continueOnError || !isSecurityStep(candidate)) {
69
+ continue;
70
+ }
71
+ const evidence = buildFindingEvidence({
72
+ textContent: input.textContent,
73
+ searchTerms: ["continue-on-error: true", candidate.run ?? "", candidate.name ?? ""],
74
+ fallbackValue: "security step continues on error",
75
+ });
76
+ findings.push({
77
+ rule_id: "workflow-misfeature",
78
+ finding_id: `WORKFLOW_MISFEATURE-${input.filePath}-${candidate.jobId}-${candidate.stepIndex}`,
79
+ severity: "MEDIUM",
80
+ category: "CI_TRIGGER",
81
+ layer: "L2",
82
+ file_path: input.filePath,
83
+ location: {
84
+ field: `jobs.${candidate.jobId}.steps[${candidate.stepIndex}].continue-on-error`,
85
+ },
86
+ description: "Security-relevant step is configured with continue-on-error, which can hide failed checks",
87
+ affected_tools: ["github-actions"],
88
+ cve: null,
89
+ owasp: ["ASI02"],
90
+ cwe: "CWE-703",
91
+ confidence: "HIGH",
92
+ fixable: false,
93
+ remediation_actions: [
94
+ "Remove continue-on-error from security-critical steps or split non-blocking diagnostics into separate jobs",
95
+ ],
96
+ evidence: evidence?.evidence ?? null,
97
+ suppressed: false,
98
+ });
99
+ }
100
+ return findings;
101
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowObfuscationInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowObfuscation(input: WorkflowObfuscationInput): Finding[];
@@ -0,0 +1,79 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const OBFUSCATION_PATTERNS = [
4
+ {
5
+ pattern: /\bbase64\s+-d\b/iu,
6
+ description: "runtime decoding of base64-encoded payload data",
7
+ },
8
+ {
9
+ pattern: /\bbase64\s+-d\b[\s\S]*\|\s*(?:bash|sh)\b/iu,
10
+ description: "base64-decoded payload piped into a shell",
11
+ },
12
+ {
13
+ pattern: /\beval\b[\s\S]*\$\([^)]+base64[^)]*\)/iu,
14
+ description: "eval executes command substitution that decodes base64 content",
15
+ },
16
+ {
17
+ pattern: /\bprintf\b[\s\S]*\\x[0-9a-f]{2}/iu,
18
+ description: "hex-encoded shell payload reconstruction",
19
+ },
20
+ ];
21
+ function detectObfuscation(run) {
22
+ if (!run) {
23
+ return null;
24
+ }
25
+ for (const entry of OBFUSCATION_PATTERNS) {
26
+ if (entry.pattern.test(run)) {
27
+ return {
28
+ run,
29
+ description: entry.description,
30
+ };
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ export function detectWorkflowObfuscation(input) {
36
+ if (!isGitHubWorkflowPath(input.filePath)) {
37
+ return [];
38
+ }
39
+ const facts = extractWorkflowFacts(input.parsed);
40
+ if (!facts) {
41
+ return [];
42
+ }
43
+ const findings = [];
44
+ facts.jobs.forEach((job, jobIndex) => {
45
+ job.steps.forEach((step, stepIndex) => {
46
+ const match = detectObfuscation(step.run);
47
+ if (!match) {
48
+ return;
49
+ }
50
+ const evidence = buildFindingEvidence({
51
+ textContent: input.textContent,
52
+ searchTerms: [match.run, "base64", "eval"],
53
+ fallbackValue: match.run,
54
+ });
55
+ findings.push({
56
+ rule_id: "workflow-obfuscation",
57
+ finding_id: `WORKFLOW_OBFUSCATION-${input.filePath}-${jobIndex}-${stepIndex}`,
58
+ severity: "HIGH",
59
+ category: "COMMAND_EXEC",
60
+ layer: "L2",
61
+ file_path: input.filePath,
62
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].run` },
63
+ description: `Workflow run step uses obfuscated command execution (${match.description})`,
64
+ affected_tools: ["github-actions"],
65
+ cve: null,
66
+ owasp: ["ASI02"],
67
+ cwe: "CWE-506",
68
+ confidence: "HIGH",
69
+ fixable: false,
70
+ remediation_actions: [
71
+ "Replace obfuscated command pipelines with explicit, reviewable commands and integrity checks",
72
+ ],
73
+ evidence: evidence?.evidence ?? null,
74
+ suppressed: false,
75
+ });
76
+ });
77
+ });
78
+ return findings;
79
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowOverprovisionedSecretsInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowOverprovisionedSecrets(input: WorkflowOverprovisionedSecretsInput): Finding[];
@@ -0,0 +1,81 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const FULL_SECRETS_SERIALIZATION = /\btojson\s*\(\s*secrets\s*\)/iu;
4
+ function asRecord(value) {
5
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
6
+ return null;
7
+ }
8
+ return value;
9
+ }
10
+ function appendPath(base, segment) {
11
+ if (typeof segment === "number") {
12
+ return `${base}[${segment}]`;
13
+ }
14
+ return base.length > 0 ? `${base}.${segment}` : segment;
15
+ }
16
+ function findSerializedSecrets(value, path) {
17
+ if (typeof value === "string") {
18
+ return FULL_SECRETS_SERIALIZATION.test(value) ? { path, value } : null;
19
+ }
20
+ if (Array.isArray(value)) {
21
+ for (let index = 0; index < value.length; index += 1) {
22
+ const found = findSerializedSecrets(value[index], appendPath(path, index));
23
+ if (found) {
24
+ return found;
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ const record = asRecord(value);
30
+ if (!record) {
31
+ return null;
32
+ }
33
+ for (const [key, child] of Object.entries(record)) {
34
+ const found = findSerializedSecrets(child, appendPath(path, key));
35
+ if (found) {
36
+ return found;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ export function detectWorkflowOverprovisionedSecrets(input) {
42
+ if (!isGitHubWorkflowPath(input.filePath)) {
43
+ return [];
44
+ }
45
+ const facts = extractWorkflowFacts(input.parsed);
46
+ if (!facts) {
47
+ return [];
48
+ }
49
+ const found = findSerializedSecrets(input.parsed, "");
50
+ if (!found) {
51
+ return [];
52
+ }
53
+ const evidence = buildFindingEvidence({
54
+ textContent: input.textContent,
55
+ searchTerms: [found.value, "toJSON(secrets)", "toJson(secrets)"],
56
+ fallbackValue: found.value,
57
+ });
58
+ return [
59
+ {
60
+ rule_id: "workflow-overprovisioned-secrets",
61
+ finding_id: `WORKFLOW_OVERPROVISIONED_SECRETS-${input.filePath}`,
62
+ severity: "HIGH",
63
+ category: "CI_PERMISSIONS",
64
+ layer: "L2",
65
+ file_path: input.filePath,
66
+ location: { field: found.path },
67
+ description: "Workflow serializes the entire secrets context",
68
+ affected_tools: ["github-actions"],
69
+ cve: null,
70
+ owasp: ["ASI02"],
71
+ cwe: "CWE-200",
72
+ confidence: "HIGH",
73
+ fixable: false,
74
+ remediation_actions: [
75
+ "Reference only the specific secrets each step needs instead of serializing the full secrets context",
76
+ ],
77
+ evidence: evidence?.evidence ?? null,
78
+ suppressed: false,
79
+ },
80
+ ];
81
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../../types/finding.js";
2
+ export interface WorkflowRefConfusionInput {
3
+ filePath: string;
4
+ parsed: unknown;
5
+ textContent: string;
6
+ }
7
+ export declare function detectWorkflowRefConfusion(input: WorkflowRefConfusionInput): Finding[];
@@ -0,0 +1,69 @@
1
+ import { buildFindingEvidence } from "../evidence.js";
2
+ import { extractWorkflowFacts, isGitHubWorkflowPath } from "../workflow/parser.js";
3
+ const HASH_PINNED_REF_RE = /^[a-f0-9]{40}$/iu;
4
+ function parseRepositoryUses(value) {
5
+ const trimmed = value.trim();
6
+ if (trimmed.startsWith("./") || trimmed.startsWith("docker://")) {
7
+ return null;
8
+ }
9
+ const atIndex = trimmed.lastIndexOf("@");
10
+ if (atIndex < 0) {
11
+ return null;
12
+ }
13
+ const slug = trimmed.slice(0, atIndex).trim().replace(/\/+$/u, "").toLowerCase();
14
+ const ref = trimmed.slice(atIndex + 1).trim();
15
+ if (slug.length === 0 || !slug.includes("/") || ref.length === 0) {
16
+ return null;
17
+ }
18
+ return { slug, ref };
19
+ }
20
+ function isHashPinned(ref) {
21
+ return HASH_PINNED_REF_RE.test(ref.trim());
22
+ }
23
+ export function detectWorkflowRefConfusion(input) {
24
+ if (!isGitHubWorkflowPath(input.filePath)) {
25
+ return [];
26
+ }
27
+ const facts = extractWorkflowFacts(input.parsed);
28
+ if (!facts) {
29
+ return [];
30
+ }
31
+ const findings = [];
32
+ for (const [jobIndex, job] of facts.jobs.entries()) {
33
+ for (const [stepIndex, step] of job.steps.entries()) {
34
+ const uses = step.uses?.trim();
35
+ if (!uses) {
36
+ continue;
37
+ }
38
+ const parsedUses = parseRepositoryUses(uses);
39
+ if (!parsedUses || isHashPinned(parsedUses.ref)) {
40
+ continue;
41
+ }
42
+ const evidence = buildFindingEvidence({
43
+ textContent: input.textContent,
44
+ searchTerms: [uses],
45
+ fallbackValue: `uses: ${uses}`,
46
+ });
47
+ findings.push({
48
+ rule_id: "workflow-ref-confusion",
49
+ finding_id: `WORKFLOW_REF_CONFUSION-${input.filePath}-${jobIndex}-${stepIndex}`,
50
+ severity: "HIGH",
51
+ category: "CI_VULNERABLE_ACTION",
52
+ layer: "L2",
53
+ file_path: input.filePath,
54
+ location: { field: `jobs.${job.id}.steps[${stepIndex}].uses` },
55
+ description: "Workflow action is pinned to a symbolic ref instead of an immutable commit hash",
56
+ affected_tools: ["github-actions"],
57
+ cve: null,
58
+ owasp: ["ASI02"],
59
+ cwe: "CWE-829",
60
+ confidence: "HIGH",
61
+ fixable: false,
62
+ remediation_actions: ["Pin external actions to a full commit SHA"],
63
+ evidence: evidence?.evidence ?? null,
64
+ suppressed: false,
65
+ });
66
+ }
67
+ }
68
+ return findings;
69
+ }
@@ -0,0 +1,9 @@
1
+ import type { RuntimeMode } from "../../config.js";
2
+ import type { Finding } from "../../types/finding.js";
3
+ export interface WorkflowRefVersionMismatchInput {
4
+ filePath: string;
5
+ parsed: unknown;
6
+ textContent: string;
7
+ runtimeMode?: RuntimeMode;
8
+ }
9
+ export declare function detectWorkflowRefVersionMismatch(input: WorkflowRefVersionMismatchInput): Promise<Finding[]>;