supply-chain-guard 5.23.0 → 5.23.2

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 (137) hide show
  1. package/README.md +36 -4
  2. package/action.yml +373 -59
  3. package/dist/agentic-workflow-scanner.d.ts.map +1 -1
  4. package/dist/agentic-workflow-scanner.js +11 -11
  5. package/dist/agentic-workflow-scanner.js.map +1 -1
  6. package/dist/archive-extractor.d.ts +11 -5
  7. package/dist/archive-extractor.d.ts.map +1 -1
  8. package/dist/archive-extractor.js +670 -8
  9. package/dist/archive-extractor.js.map +1 -1
  10. package/dist/broad-gap-pattern-matchers.d.ts +17 -0
  11. package/dist/broad-gap-pattern-matchers.d.ts.map +1 -0
  12. package/dist/broad-gap-pattern-matchers.js +921 -0
  13. package/dist/broad-gap-pattern-matchers.js.map +1 -0
  14. package/dist/cargo-scanner.d.ts.map +1 -1
  15. package/dist/cargo-scanner.js +171 -71
  16. package/dist/cargo-scanner.js.map +1 -1
  17. package/dist/cli.js +239 -77
  18. package/dist/cli.js.map +1 -1
  19. package/dist/composer-scanner.d.ts.map +1 -1
  20. package/dist/composer-scanner.js +9 -15
  21. package/dist/composer-scanner.js.map +1 -1
  22. package/dist/config-scanner.d.ts.map +1 -1
  23. package/dist/config-scanner.js +5 -4
  24. package/dist/config-scanner.js.map +1 -1
  25. package/dist/continuous-monitor.d.ts.map +1 -1
  26. package/dist/continuous-monitor.js +4 -0
  27. package/dist/continuous-monitor.js.map +1 -1
  28. package/dist/correlated-pattern-matchers.d.ts +13 -0
  29. package/dist/correlated-pattern-matchers.d.ts.map +1 -0
  30. package/dist/correlated-pattern-matchers.js +949 -0
  31. package/dist/correlated-pattern-matchers.js.map +1 -0
  32. package/dist/dependency-confusion.js +4 -4
  33. package/dist/dependency-confusion.js.map +1 -1
  34. package/dist/dockerfile-scanner.d.ts.map +1 -1
  35. package/dist/dockerfile-scanner.js +189 -4
  36. package/dist/dockerfile-scanner.js.map +1 -1
  37. package/dist/extracted-file-walker.d.ts +25 -0
  38. package/dist/extracted-file-walker.d.ts.map +1 -0
  39. package/dist/extracted-file-walker.js +218 -0
  40. package/dist/extracted-file-walker.js.map +1 -0
  41. package/dist/git-scanner.d.ts.map +1 -1
  42. package/dist/git-scanner.js +99 -78
  43. package/dist/git-scanner.js.map +1 -1
  44. package/dist/github-actions-scanner.d.ts +7 -1
  45. package/dist/github-actions-scanner.d.ts.map +1 -1
  46. package/dist/github-actions-scanner.js +36 -25
  47. package/dist/github-actions-scanner.js.map +1 -1
  48. package/dist/github-trust-scanner.d.ts.map +1 -1
  49. package/dist/github-trust-scanner.js +14 -20
  50. package/dist/github-trust-scanner.js.map +1 -1
  51. package/dist/go-scanner.d.ts.map +1 -1
  52. package/dist/go-scanner.js +149 -61
  53. package/dist/go-scanner.js.map +1 -1
  54. package/dist/internal-disclosure.d.ts +4 -0
  55. package/dist/internal-disclosure.d.ts.map +1 -1
  56. package/dist/internal-disclosure.js +2 -1
  57. package/dist/internal-disclosure.js.map +1 -1
  58. package/dist/ioc-blocklist.d.ts.map +1 -1
  59. package/dist/ioc-blocklist.js +112 -0
  60. package/dist/ioc-blocklist.js.map +1 -1
  61. package/dist/lockfile-checker.d.ts.map +1 -1
  62. package/dist/lockfile-checker.js +23 -39
  63. package/dist/lockfile-checker.js.map +1 -1
  64. package/dist/mcp-scanner.d.ts.map +1 -1
  65. package/dist/mcp-scanner.js +5 -3
  66. package/dist/mcp-scanner.js.map +1 -1
  67. package/dist/mcp-server.d.ts.map +1 -1
  68. package/dist/mcp-server.js +3 -1
  69. package/dist/mcp-server.js.map +1 -1
  70. package/dist/npm-scanner.d.ts +10 -1
  71. package/dist/npm-scanner.d.ts.map +1 -1
  72. package/dist/npm-scanner.js +87 -57
  73. package/dist/npm-scanner.js.map +1 -1
  74. package/dist/nuget-scanner.d.ts.map +1 -1
  75. package/dist/nuget-scanner.js +24 -22
  76. package/dist/nuget-scanner.js.map +1 -1
  77. package/dist/pattern-applicability.d.ts +23 -0
  78. package/dist/pattern-applicability.d.ts.map +1 -0
  79. package/dist/pattern-applicability.js +102 -0
  80. package/dist/pattern-applicability.js.map +1 -0
  81. package/dist/pattern-scanner.d.ts +53 -0
  82. package/dist/pattern-scanner.d.ts.map +1 -0
  83. package/dist/pattern-scanner.js +336 -0
  84. package/dist/pattern-scanner.js.map +1 -0
  85. package/dist/patterns.d.ts +97 -28
  86. package/dist/patterns.d.ts.map +1 -1
  87. package/dist/patterns.js +1175 -103
  88. package/dist/patterns.js.map +1 -1
  89. package/dist/posture-engine.d.ts +4 -0
  90. package/dist/posture-engine.d.ts.map +1 -1
  91. package/dist/posture-engine.js +15 -3
  92. package/dist/posture-engine.js.map +1 -1
  93. package/dist/pypi-scanner.d.ts +34 -1
  94. package/dist/pypi-scanner.d.ts.map +1 -1
  95. package/dist/pypi-scanner.js +315 -98
  96. package/dist/pypi-scanner.js.map +1 -1
  97. package/dist/python-lockfile-scanner.d.ts.map +1 -1
  98. package/dist/python-lockfile-scanner.js +12 -19
  99. package/dist/python-lockfile-scanner.js.map +1 -1
  100. package/dist/regex-complexity.d.ts +12 -0
  101. package/dist/regex-complexity.d.ts.map +1 -0
  102. package/dist/regex-complexity.js +173 -0
  103. package/dist/regex-complexity.js.map +1 -0
  104. package/dist/reporter.d.ts +9 -1
  105. package/dist/reporter.d.ts.map +1 -1
  106. package/dist/reporter.js +142 -30
  107. package/dist/reporter.js.map +1 -1
  108. package/dist/rubygems-scanner.d.ts.map +1 -1
  109. package/dist/rubygems-scanner.js +9 -15
  110. package/dist/rubygems-scanner.js.map +1 -1
  111. package/dist/sbom-generator.js +1 -1
  112. package/dist/sbom-generator.js.map +1 -1
  113. package/dist/scanner.d.ts +6 -0
  114. package/dist/scanner.d.ts.map +1 -1
  115. package/dist/scanner.js +261 -247
  116. package/dist/scanner.js.map +1 -1
  117. package/dist/skills-scanner.d.ts.map +1 -1
  118. package/dist/skills-scanner.js +198 -141
  119. package/dist/skills-scanner.js.map +1 -1
  120. package/dist/soc-exporter.d.ts.map +1 -1
  121. package/dist/soc-exporter.js +38 -5
  122. package/dist/soc-exporter.js.map +1 -1
  123. package/dist/threat-intel.d.ts.map +1 -1
  124. package/dist/threat-intel.js +340 -0
  125. package/dist/threat-intel.js.map +1 -1
  126. package/dist/types.d.ts +32 -0
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/types.js.map +1 -1
  129. package/dist/vscode-scanner.d.ts +16 -1
  130. package/dist/vscode-scanner.d.ts.map +1 -1
  131. package/dist/vscode-scanner.js +289 -119
  132. package/dist/vscode-scanner.js.map +1 -1
  133. package/dist/workflow-pattern-matchers.d.ts +15 -0
  134. package/dist/workflow-pattern-matchers.d.ts.map +1 -0
  135. package/dist/workflow-pattern-matchers.js +303 -0
  136. package/dist/workflow-pattern-matchers.js.map +1 -0
  137. package/package.json +2 -1
package/README.md CHANGED
@@ -131,7 +131,7 @@ Run the scanner as a [pre-commit](https://pre-commit.com) hook (Python-ecosystem
131
131
  ```yaml
132
132
  repos:
133
133
  - repo: https://github.com/homeofe/supply-chain-guard
134
- rev: v5.23.0
134
+ rev: v5.23.2
135
135
  hooks:
136
136
  - id: supply-chain-guard
137
137
  ```
@@ -201,6 +201,7 @@ supply-chain-guard scan ./project --format sbom # CycloneDX 1.6 SBOM with real
201
201
  supply-chain-guard scan ./project --sbom-output sbom.json # Write SBOM to file separately
202
202
  supply-chain-guard scan ./project --format badge # Shields.io endpoint JSON
203
203
  supply-chain-guard scan ./project --format gitlab # GitLab Dependency Scanning report (security-report-schemas 15.2.4, see examples/gitlab-ci.yml)
204
+ supply-chain-guard scan ./project --format markdown --json-output canonical.json # Same scan, human report plus canonical JSON
204
205
  ```
205
206
 
206
207
  ### Badge
@@ -233,6 +234,12 @@ supply-chain-guard scan ./project --fail-on high # Fail on high or above
233
234
  supply-chain-guard scan ./project --fail-on info # Fail on any finding
234
235
  ```
235
236
 
237
+ `--min-severity` may reduce report noise, but it cannot be stricter than the
238
+ active `--fail-on` gate because that would hide findings required for the exit
239
+ verdict. Invalid combinations fail before scanning. Incomplete coverage also
240
+ exits nonzero regardless of the severity threshold and is reported as
241
+ `partialScan: true` in JSON.
242
+
236
243
  ## Filtering
237
244
 
238
245
  ```bash
@@ -585,6 +592,10 @@ Honest caveats: Socket's registry-wide behavioral detection is deeper than anyth
585
592
  name: Supply Chain Security
586
593
  on: [push, pull_request]
587
594
 
595
+ permissions:
596
+ contents: read
597
+ pull-requests: write
598
+
588
599
  jobs:
589
600
  scan:
590
601
  runs-on: ubuntu-latest
@@ -601,11 +612,32 @@ jobs:
601
612
  | Input | Description | Default |
602
613
  |-------|-------------|---------|
603
614
  | `path` | Path to scan | `.` |
604
- | `format` | Output format (text/json/markdown/html/sarif/sbom) | `markdown` |
615
+ | `format` | Output format (text/json/markdown/sarif/sbom/html/badge/gitlab/junit) | `markdown` |
605
616
  | `min-severity` | Minimum severity to report | `low` |
606
617
  | `exclude-rules` | Comma-separated rule IDs to exclude | |
607
- | `fail-on` | Fail check at this severity or above | `critical` |
608
- | `comment-on-pr` | Post findings as PR comment | `true` |
618
+ | `fail-on` | Fail check at this severity or above, including `info` | `critical` |
619
+ | `comment-on-pr` | Post or update a PR comment | `true` |
620
+
621
+ Coverage failures are fail-closed regardless of `fail-on`: the Action exits
622
+ nonzero, sets `partial-scan` to `true` and `risk-level` to `partial`, and posts
623
+ a warning even when severity filters hide the informational coverage finding.
624
+ A critical threshold failure retains exit code 2; other partial results exit 1.
625
+
626
+ PR comments require `pull-requests: write` (shown above). GitHub restricts write
627
+ access for pull requests from forks, so the check verdict, job log, and outputs
628
+ remain authoritative when the platform refuses a comment.
629
+
630
+ ### Action Outputs
631
+
632
+ | Output | Description |
633
+ |--------|-------------|
634
+ | `score` | Risk score from 0 to 100 |
635
+ | `risk-level` | `partial`, `clean`, `low`, `medium`, `high`, or `critical` |
636
+ | `findings-count` | Number of reportable findings after filters |
637
+ | `partial-scan` | `true` when coverage was incomplete |
638
+ | `report` | Requested report, or a size notice when it exceeds the safe output budget |
639
+ | `report-path` | Runner-local path to the complete report for later steps in the same job |
640
+ | `report-truncated` | `true` when `report` was replaced by the size notice |
609
641
 
610
642
  ## For AI Coding Agents (MCP)
611
643
 
package/action.yml CHANGED
@@ -24,7 +24,7 @@ inputs:
24
24
  required: false
25
25
  default: ""
26
26
  fail-on:
27
- description: "Fail the check if findings at this severity or above are found: critical, high, medium, low"
27
+ description: "Fail the check if findings at this severity or above are found: critical, high, medium, low, info"
28
28
  required: false
29
29
  default: "critical"
30
30
  comment-on-pr:
@@ -35,24 +35,38 @@ inputs:
35
35
  outputs:
36
36
  score:
37
37
  description: "Risk score (0-100)"
38
+ value: ${{ steps.scan.outputs.score }}
38
39
  risk-level:
39
- description: "Risk level: clean, low, medium, high, critical"
40
+ description: "Risk level: partial, clean, low, medium, high, critical"
41
+ value: ${{ steps.scan.outputs['risk-level'] }}
40
42
  findings-count:
41
- description: "Total number of findings"
43
+ description: "Number of reportable findings after filters"
44
+ value: ${{ steps.scan.outputs['findings-count'] }}
45
+ partial-scan:
46
+ description: "Whether coverage was incomplete and the check failed closed"
47
+ value: ${{ steps.scan.outputs['partial-scan'] }}
42
48
  report:
43
- description: "Full scan report in the requested format"
49
+ description: "Scan report in the requested format (bounded for GitHub output limits)"
50
+ value: ${{ steps.scan.outputs.report }}
51
+ report-path:
52
+ description: "Runner-local path containing the complete scan report"
53
+ value: ${{ steps.scan.outputs['report-path'] }}
54
+ report-truncated:
55
+ description: "Whether the report output was replaced by a size-limit notice"
56
+ value: ${{ steps.scan.outputs['report-truncated'] }}
44
57
 
45
58
  runs:
46
59
  using: "composite"
47
60
  steps:
48
61
  - name: Setup Node.js
49
- uses: actions/setup-node@v7
62
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
50
63
  with:
51
64
  node-version: "22"
65
+ package-manager-cache: false
52
66
 
53
67
  - name: Install supply-chain-guard
54
68
  shell: bash
55
- run: npm install -g supply-chain-guard
69
+ run: npm install -g supply-chain-guard@5.23.2 --ignore-scripts --no-audit --no-fund
56
70
 
57
71
  - name: Run scan
58
72
  id: scan
@@ -60,18 +74,53 @@ runs:
60
74
  env:
61
75
  SCG_PATH: ${{ inputs.path }}
62
76
  SCG_FORMAT: ${{ inputs.format }}
63
- SCG_MIN_SEVERITY: ${{ inputs.min-severity }}
64
- SCG_EXCLUDE_RULES: ${{ inputs.exclude-rules }}
65
- SCG_FAIL_ON: ${{ inputs.fail-on }}
77
+ SCG_MIN_SEVERITY: ${{ inputs['min-severity'] }}
78
+ SCG_EXCLUDE_RULES: ${{ inputs['exclude-rules'] }}
79
+ SCG_FAIL_ON: ${{ inputs['fail-on'] }}
80
+ SCG_COMMENT_ON_PR: ${{ inputs['comment-on-pr'] }}
66
81
  run: |
67
82
  set +e
68
83
 
69
84
  # Inputs arrive via env (set by the runner), never interpolated into this
70
- # script at template-render time. Build the filter flags as an array so
71
- # values cannot word-split or be parsed by the shell. The SAME filters
72
- # feed both the human report and the JSON metrics so they describe one
73
- # finding set.
74
- FILTER_ARGS=()
85
+ # script at template-render time. Validate every enum here: Commander
86
+ # validates its own invocation, but the Action must not turn a typo into
87
+ # an ignored gate or a successful scan.
88
+ SCG_FORMAT="${SCG_FORMAT:-markdown}"
89
+ FAIL_SEVERITY="${SCG_FAIL_ON:-critical}"
90
+ COMMENT_ON_PR="${SCG_COMMENT_ON_PR:-true}"
91
+ case "$SCG_FORMAT" in
92
+ text|json|markdown|sarif|sbom|html|badge|gitlab|junit) ;;
93
+ *) echo "Invalid format input. Allowed: text, json, markdown, sarif, sbom, html, badge, gitlab, junit." >&2; exit 1 ;;
94
+ esac
95
+ case "$COMMENT_ON_PR" in
96
+ true|false) ;;
97
+ *) echo "Invalid comment-on-pr input. Allowed: true, false." >&2; exit 1 ;;
98
+ esac
99
+ case "$SCG_MIN_SEVERITY" in
100
+ ""|info) MIN_SEVERITY_RANK=0 ;;
101
+ low) MIN_SEVERITY_RANK=1 ;;
102
+ medium) MIN_SEVERITY_RANK=2 ;;
103
+ high) MIN_SEVERITY_RANK=3 ;;
104
+ critical) MIN_SEVERITY_RANK=4 ;;
105
+ *) echo "Invalid min-severity input. Allowed: info, low, medium, high, critical." >&2; exit 1 ;;
106
+ esac
107
+ case "$FAIL_SEVERITY" in
108
+ info) FAIL_SEVERITY_RANK=0 ;;
109
+ low) FAIL_SEVERITY_RANK=1 ;;
110
+ medium) FAIL_SEVERITY_RANK=2 ;;
111
+ high) FAIL_SEVERITY_RANK=3 ;;
112
+ critical) FAIL_SEVERITY_RANK=4 ;;
113
+ *) echo "Invalid fail-on input. Allowed: info, low, medium, high, critical." >&2; exit 1 ;;
114
+ esac
115
+ if [ "$MIN_SEVERITY_RANK" -gt "$FAIL_SEVERITY_RANK" ]; then
116
+ echo "Invalid severity thresholds: min-severity '$SCG_MIN_SEVERITY' would hide findings required by fail-on '$FAIL_SEVERITY'." >&2
117
+ exit 1
118
+ fi
119
+
120
+ # Build the filter flags as an array so values cannot word-split or be
121
+ # parsed by the shell. The scan is read-only, and --no-history prevents
122
+ # it from mutating the repository while producing both report formats.
123
+ FILTER_ARGS=(--no-history --fail-on "$FAIL_SEVERITY")
75
124
  if [ -n "$SCG_MIN_SEVERITY" ]; then
76
125
  FILTER_ARGS+=(--min-severity "$SCG_MIN_SEVERITY")
77
126
  fi
@@ -79,88 +128,353 @@ runs:
79
128
  FILTER_ARGS+=(--exclude "$SCG_EXCLUDE_RULES")
80
129
  fi
81
130
 
82
- # JSON drives the machine-readable outputs (score / risk-level /
83
- # findings-count) and the fail-on gate below, so it is the canonical
84
- # single scan. Only run a SECOND scan when the requested human format is
85
- # not already JSON (previously json was ALWAYS re-scanned, doubling CI
86
- # time even when the requested format WAS json).
87
- JSON_FILE="$RUNNER_TEMP/scg-report.json"
88
- if [ "$SCG_FORMAT" = "json" ]; then
89
- supply-chain-guard scan "$SCG_PATH" --format json "${FILTER_ARGS[@]}" > "$JSON_FILE" 2>/dev/null
90
- REPORT=$(cat "$JSON_FILE")
131
+ # One scan produces both payloads from the same in-memory ScanReport.
132
+ # The canonical JSON drives policy; stdout carries the requested format.
133
+ SCG_RUN_DIR=$(mktemp -d "$RUNNER_TEMP/scg-action.XXXXXX") || {
134
+ echo "Could not create the isolated scan output directory." >&2
135
+ exit 1
136
+ }
137
+ JSON_FILE="$SCG_RUN_DIR/canonical-report.json"
138
+ FORMAT_FILE="$SCG_RUN_DIR/requested-report"
139
+ FORMAT_ERR="$SCG_RUN_DIR/format.stderr"
140
+ supply-chain-guard scan "$SCG_PATH" --format "$SCG_FORMAT" \
141
+ --json-output "$JSON_FILE" "${FILTER_ARGS[@]}" \
142
+ > "$FORMAT_FILE" 2>"$FORMAT_ERR"
143
+ SCAN_STATUS=$?
144
+ set -e
145
+ JSON_STATUS=$SCAN_STATUS
146
+ FORMAT_STATUS=$SCAN_STATUS
147
+ # Validate every field consumed by the gate or published through a
148
+ # single-line output. Exact per-severity counts prevent a malformed
149
+ # summary from under-reporting a finding and passing the threshold.
150
+ if ! jq -e '
151
+ def nonnegint: type == "number" and . >= 0 and floor == .;
152
+ def severity_score:
153
+ if . == "critical" then 25
154
+ elif . == "high" then 15
155
+ elif . == "medium" then 5
156
+ elif . == "low" then 2
157
+ else 1
158
+ end;
159
+ def minimum_visible_score:
160
+ [.findings[]
161
+ | select(.rule != "CRITICAL_FINDING_NO_OWNER"
162
+ and .rule != "RISK_STAGNATION_HIGH")
163
+ | { rule: .rule, score: (.severity | severity_score) }]
164
+ | group_by(.rule)
165
+ | map(map(.score) | max)
166
+ | (add // 0)
167
+ | if . > 100 then 100 else . end;
168
+ def coverage_rule:
169
+ . == "FILE_TOO_LARGE_SKIPPED"
170
+ or . == "PATTERN_SCAN_INCOMPLETE"
171
+ or . == "PATH_SCAN_INCOMPLETE"
172
+ or . == "INTERNAL_DISCLOSURE_TRUNCATED"
173
+ or . == "PYPI_NO_SOURCE"
174
+ or . == "NPM_NO_ARTIFACT"
175
+ or . == "INTERNAL_DENYLIST_UNAVAILABLE"
176
+ or . == "INTERNAL_DENYLIST_INVALID_ENTRY"
177
+ or . == "POLICY_INVALID_INTERNAL_TERM";
178
+ type == "object"
179
+ and (.summary | type == "object")
180
+ and (.findings | type == "array")
181
+ and ([.summary.totalFiles, .summary.filesScanned, .summary.critical,
182
+ .summary.high, .summary.medium, .summary.low, .summary.info]
183
+ | all(nonnegint))
184
+ and (.summary.filesScanned <= .summary.totalFiles)
185
+ and (.score | nonnegint and . <= 100)
186
+ and (.score >= (minimum_visible_score))
187
+ and (.riskLevel == "clean" or .riskLevel == "low" or .riskLevel == "medium"
188
+ or .riskLevel == "high" or .riskLevel == "critical")
189
+ and ((.score == 0 and .riskLevel == "clean")
190
+ or (.score >= 1 and .score <= 10 and .riskLevel == "low")
191
+ or (.score >= 11 and .score <= 30 and .riskLevel == "medium")
192
+ or (.score >= 31 and .score <= 60 and .riskLevel == "high")
193
+ or (.score >= 61 and .riskLevel == "critical"))
194
+ and ((has("partialScan") | not) or (.partialScan | type == "boolean"))
195
+ and all(.findings[];
196
+ type == "object"
197
+ and (.rule | type == "string" and length > 0)
198
+ and (.severity == "critical" or .severity == "high" or .severity == "medium"
199
+ or .severity == "low" or .severity == "info"))
200
+ and (([.findings[] | select(.rule | coverage_rule)] | length) == 0
201
+ or .partialScan == true)
202
+ and (.summary.critical == ([.findings[] | select(.severity == "critical")] | length))
203
+ and (.summary.high == ([.findings[] | select(.severity == "high")] | length))
204
+ and (.summary.medium == ([.findings[] | select(.severity == "medium")] | length))
205
+ and (.summary.low == ([.findings[] | select(.severity == "low")] | length))
206
+ and (.summary.info == ([.findings[] | select(.severity == "info")] | length))
207
+ ' "$JSON_FILE" >/dev/null; then
208
+ echo "supply-chain-guard did not produce a valid JSON scan report; failing closed." >&2
209
+ exit 1
210
+ fi
211
+
212
+ # Extract only schema-validated scalar values.
213
+ SCORE=$(jq -r '.score' "$JSON_FILE")
214
+ PARTIAL_SCAN=$(jq -r '.partialScan // false' "$JSON_FILE")
215
+ RISK_LEVEL=$(jq -r '.riskLevel' "$JSON_FILE")
216
+ FINDINGS_COUNT=$(jq -r '.findings | length' "$JSON_FILE")
217
+ if [ "$PARTIAL_SCAN" = "true" ]; then
218
+ RISK_LEVEL="partial"
219
+ fi
220
+
221
+ # Validate that the requested formatter produced its documented payload,
222
+ # not an error message whose exit code happens to equal a finding gate.
223
+ FORMAT_VALID=false
224
+ case "$SCG_FORMAT" in
225
+ json)
226
+ jq -s -e 'length == 2 and .[0] == .[1]' "$FORMAT_FILE" "$JSON_FILE" >/dev/null && FORMAT_VALID=true
227
+ ;;
228
+ sarif)
229
+ jq -e '.version == "2.1.0" and (.runs | type == "array") and (.runs[0].tool.driver.name == "supply-chain-guard") and (.runs[0].results | type == "array")' "$FORMAT_FILE" >/dev/null && FORMAT_VALID=true
230
+ ;;
231
+ sbom)
232
+ jq -e '.bomFormat == "CycloneDX" and .specVersion == "1.6" and (.vulnerabilities | type == "array")' "$FORMAT_FILE" >/dev/null && FORMAT_VALID=true
233
+ ;;
234
+ badge)
235
+ jq -e '.schemaVersion == 1 and .label == "supply-chain-guard" and (.message | type == "string") and (.color | type == "string")' "$FORMAT_FILE" >/dev/null && FORMAT_VALID=true
236
+ ;;
237
+ gitlab)
238
+ jq -e '.version == "15.2.4" and .scan.scanner.name == "supply-chain-guard" and (.vulnerabilities | type == "array")' "$FORMAT_FILE" >/dev/null && FORMAT_VALID=true
239
+ ;;
240
+ text)
241
+ grep -Fq 'supply-chain-guard' "$FORMAT_FILE" && grep -Fq 'FINDINGS SUMMARY' "$FORMAT_FILE" && FORMAT_VALID=true
242
+ ;;
243
+ markdown)
244
+ grep -Fq 'supply-chain-guard Scan Report' "$FORMAT_FILE" && grep -Fq '| Property | Value |' "$FORMAT_FILE" && FORMAT_VALID=true
245
+ ;;
246
+ html)
247
+ grep -Fq '<!DOCTYPE html>' "$FORMAT_FILE" && grep -Fq '<h1>supply-chain-guard Scan Report</h1>' "$FORMAT_FILE" && grep -Fq '</html>' "$FORMAT_FILE" && FORMAT_VALID=true
248
+ ;;
249
+ junit)
250
+ grep -Fq '<?xml version="1.0" encoding="UTF-8"?>' "$FORMAT_FILE" && grep -Fq '<testsuite name="supply-chain-guard"' "$FORMAT_FILE" && grep -Fq '</testsuite>' "$FORMAT_FILE" && FORMAT_VALID=true
251
+ ;;
252
+ esac
253
+
254
+ # The CLI receives the same explicit gate as this Action. Its explicit
255
+ # --fail-on contract exits 1 for a threshold hit (including critical)
256
+ # or partial coverage, and 0 otherwise. The Action maps a critical gate
257
+ # hit to its documented public exit 2 after this reconciliation.
258
+ EXPECTED_STATUS=0
259
+ if [ "$PARTIAL_SCAN" = "true" ]; then
260
+ EXPECTED_STATUS=1
91
261
  else
92
- REPORT=$(supply-chain-guard scan "$SCG_PATH" --format "$SCG_FORMAT" "${FILTER_ARGS[@]}" 2>&1)
93
- supply-chain-guard scan "$SCG_PATH" --format json "${FILTER_ARGS[@]}" > "$JSON_FILE" 2>/dev/null
262
+ case "$FAIL_SEVERITY" in
263
+ critical) GATE_QUERY='.summary.critical > 0' ;;
264
+ high) GATE_QUERY='.summary.critical + .summary.high > 0' ;;
265
+ medium) GATE_QUERY='.summary.critical + .summary.high + .summary.medium > 0' ;;
266
+ low) GATE_QUERY='.summary.critical + .summary.high + .summary.medium + .summary.low > 0' ;;
267
+ info) GATE_QUERY='.summary.critical + .summary.high + .summary.medium + .summary.low + .summary.info > 0' ;;
268
+ esac
269
+ if jq -e "$GATE_QUERY" "$JSON_FILE" >/dev/null; then
270
+ EXPECTED_STATUS=1
271
+ fi
94
272
  fi
95
- JSON_REPORT=$(cat "$JSON_FILE")
96
-
97
- # Extract values from JSON
98
- SCORE=$(echo "$JSON_REPORT" | jq -r '.score // 0')
99
- RISK_LEVEL=$(echo "$JSON_REPORT" | jq -r '.riskLevel // "unknown"')
100
- FINDINGS_COUNT=$(echo "$JSON_REPORT" | jq -r '.findings | length // 0')
101
-
102
- echo "score=$SCORE" >> $GITHUB_OUTPUT
103
- echo "risk-level=$RISK_LEVEL" >> $GITHUB_OUTPUT
104
- echo "findings-count=$FINDINGS_COUNT" >> $GITHUB_OUTPUT
105
-
106
- # Save report to a per-job temp file for multi-line output. RUNNER_TEMP
107
- # is per-job and cross-platform; a random heredoc delimiter prevents scan
108
- # output from closing the block early and injecting step outputs.
109
- echo "$REPORT" > "$RUNNER_TEMP/scg-report.txt"
110
- REPORT_DELIM="SCG_REPORT_$(date +%s%N)_${RANDOM}${RANDOM}"
273
+ PASSES_CONSISTENT=false
274
+ if [ "$FORMAT_VALID" = "true" ] \
275
+ && [ "$JSON_STATUS" -eq "$EXPECTED_STATUS" ] \
276
+ && [ "$FORMAT_STATUS" -eq "$EXPECTED_STATUS" ]; then
277
+ PASSES_CONSISTENT=true
278
+ fi
279
+
280
+ # Keep the complete report on disk. GitHub limits all step outputs in a
281
+ # job to roughly 1 MB (UTF-16 accounting), so publish at most 400 KB and
282
+ # expose the runner-local path for callers that need the complete file.
283
+ REPORT_PATH="$SCG_RUN_DIR/report.txt"
284
+ cp "$FORMAT_FILE" "$REPORT_PATH"
285
+ REPORT_BYTES=$(wc -c < "$REPORT_PATH")
286
+ REPORT_TRUNCATED=false
287
+ if [ "$REPORT_BYTES" -gt 400000 ]; then
288
+ REPORT_TRUNCATED=true
289
+ fi
290
+
291
+ REPORT_DELIM="SCG_REPORT_$(node -p "require('node:crypto').randomUUID()")"
111
292
  {
293
+ echo "score=$SCORE"
294
+ echo "risk-level=$RISK_LEVEL"
295
+ echo "findings-count=$FINDINGS_COUNT"
296
+ echo "partial-scan=$PARTIAL_SCAN"
297
+ echo "report-valid=true"
298
+ echo "passes-consistent=$PASSES_CONSISTENT"
299
+ echo "report-path=$REPORT_PATH"
300
+ echo "report-truncated=$REPORT_TRUNCATED"
112
301
  echo "report<<$REPORT_DELIM"
113
- cat "$RUNNER_TEMP/scg-report.txt"
302
+ if [ "$REPORT_TRUNCATED" = "true" ]; then
303
+ echo "Report exceeds the safe GitHub output limit ($REPORT_BYTES bytes). The complete report is retained at the report-path output."
304
+ else
305
+ cat "$REPORT_PATH"
306
+ fi
114
307
  echo "$REPORT_DELIM"
115
- } >> $GITHUB_OUTPUT
308
+ } >> "$GITHUB_OUTPUT"
116
309
 
117
- # Print report to log
118
- echo "$REPORT"
310
+ # Keep logs bounded as well as step outputs. The complete report remains
311
+ # available through report-path for later steps in this job.
312
+ LOG_BYTE_LIMIT=200000
313
+ LOG_COMMAND_TOKEN="SCG_LOG_$(node -p "require('node:crypto').randomUUID()")"
314
+ echo "::stop-commands::$LOG_COMMAND_TOKEN"
315
+ if [ "$REPORT_BYTES" -gt "$LOG_BYTE_LIMIT" ]; then
316
+ head -c "$LOG_BYTE_LIMIT" "$REPORT_PATH"
317
+ echo
318
+ echo "[supply-chain-guard report log truncated at $LOG_BYTE_LIMIT bytes]"
319
+ else
320
+ cat "$REPORT_PATH"
321
+ fi
322
+ echo
323
+ echo "::$LOG_COMMAND_TOKEN::"
119
324
 
120
- # Determine if we should fail
121
- FAIL_SEVERITY="$SCG_FAIL_ON"
325
+ # A malformed formatter payload or command-status disagreement is an
326
+ # operational failure, even when exit 1 could also mean findings.
327
+ if [ "$PASSES_CONSISTENT" != "true" ]; then
328
+ if [ -s "$FORMAT_ERR" ]; then
329
+ ERROR_COMMAND_TOKEN="SCG_ERR_$(node -p "require('node:crypto').randomUUID()")"
330
+ echo "::stop-commands::$ERROR_COMMAND_TOKEN"
331
+ head -c "$LOG_BYTE_LIMIT" "$FORMAT_ERR"
332
+ echo
333
+ echo "::$ERROR_COMMAND_TOKEN::"
334
+ fi
335
+ echo "Scan status or requested-format payload was invalid; failing closed (scan=$SCAN_STATUS, expected=$EXPECTED_STATUS)." >&2
336
+ exit 1
337
+ fi
338
+
339
+ # Determine if we should fail from validated counts.
122
340
  case "$FAIL_SEVERITY" in
123
341
  critical)
124
- [ "$(echo "$JSON_REPORT" | jq '.summary.critical')" -gt 0 ] && exit 2
342
+ jq -e '.summary.critical > 0' "$JSON_FILE" >/dev/null && exit 2
125
343
  ;;
126
344
  high)
127
- [ "$(echo "$JSON_REPORT" | jq '.summary.critical + .summary.high')" -gt 0 ] && exit 1
345
+ jq -e '.summary.critical + .summary.high > 0' "$JSON_FILE" >/dev/null && exit 1
128
346
  ;;
129
347
  medium)
130
- [ "$(echo "$JSON_REPORT" | jq '.summary.critical + .summary.high + .summary.medium')" -gt 0 ] && exit 1
348
+ jq -e '.summary.critical + .summary.high + .summary.medium > 0' "$JSON_FILE" >/dev/null && exit 1
131
349
  ;;
132
350
  low)
133
- [ "$(echo "$JSON_REPORT" | jq '.summary.critical + .summary.high + .summary.medium + .summary.low')" -gt 0 ] && exit 1
351
+ jq -e '.summary.critical + .summary.high + .summary.medium + .summary.low > 0' "$JSON_FILE" >/dev/null && exit 1
352
+ ;;
353
+ info)
354
+ jq -e '.summary.critical + .summary.high + .summary.medium + .summary.low + .summary.info > 0' "$JSON_FILE" >/dev/null && exit 1
134
355
  ;;
135
356
  esac
136
357
 
358
+ # Coverage failures are independent of the finding-severity threshold.
359
+ # A partial scan can never prove that no threshold finding exists.
360
+ if [ "$PARTIAL_SCAN" = "true" ]; then
361
+ exit 1
362
+ fi
363
+
137
364
  exit 0
138
365
 
139
366
  - name: Comment on PR
140
- if: inputs.comment-on-pr == 'true' && github.event_name == 'pull_request' && steps.scan.outputs.findings-count != '0'
141
- uses: actions/github-script@v9
367
+ if: always() && inputs['comment-on-pr'] == 'true' && github.event_name == 'pull_request'
368
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
142
369
  continue-on-error: true
370
+ env:
371
+ SCG_PARTIAL_SCAN: ${{ steps.scan.outputs['partial-scan'] }}
372
+ SCG_REPORT_VALID: ${{ steps.scan.outputs['report-valid'] }}
373
+ SCG_PASSES_CONSISTENT: ${{ steps.scan.outputs['passes-consistent'] }}
374
+ SCG_FINDINGS_COUNT: ${{ steps.scan.outputs['findings-count'] }}
375
+ SCG_SCAN_OUTCOME: ${{ steps.scan.outcome }}
376
+ SCG_REPORT_PATH: ${{ steps.scan.outputs['report-path'] }}
143
377
  with:
144
378
  script: |
145
379
  const fs = require('fs');
380
+ const marker = '## supply-chain-guard Scan Report';
381
+ const reportValid = process.env.SCG_REPORT_VALID === 'true';
382
+ const passesConsistent = process.env.SCG_PASSES_CONSISTENT === 'true';
383
+ const partial = process.env.SCG_PARTIAL_SCAN === 'true';
384
+ const parsedCount = Number.parseInt(process.env.SCG_FINDINGS_COUNT ?? '', 10);
385
+ const findingsCount = Number.isSafeInteger(parsedCount) && parsedCount >= 0
386
+ ? parsedCount
387
+ : null;
388
+ const scanOutcome = process.env.SCG_SCAN_OUTCOME || 'unavailable';
389
+
390
+ // Read only the bytes that can be useful in a GitHub comment. This
391
+ // keeps a maliciously large formatter payload from exhausting memory.
392
+ const reportByteLimit = 60000;
146
393
  let report = '';
394
+ let reportReadTruncated = false;
147
395
  try {
148
- report = fs.readFileSync(process.env.RUNNER_TEMP + '/scg-report.txt', 'utf8');
396
+ const descriptor = fs.openSync(process.env.SCG_REPORT_PATH, 'r');
397
+ try {
398
+ const buffer = Buffer.alloc(reportByteLimit + 1);
399
+ const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
400
+ reportReadTruncated = bytesRead > reportByteLimit;
401
+ report = buffer.subarray(0, Math.min(bytesRead, reportByteLimit)).toString('utf8').trim();
402
+ } finally {
403
+ fs.closeSync(descriptor);
404
+ }
149
405
  } catch (err) {
150
- console.error('Failed to read report file:', err);
406
+ console.error('Failed to read the bounded report excerpt:', err);
407
+ }
408
+
409
+ // Escape HTML and neutralize mentions before rendering the report as
410
+ // inert code in the PR comment.
411
+ const escapeReport = (value) => value
412
+ .replace(/&/g, '&amp;')
413
+ .replace(/</g, '&lt;')
414
+ .replace(/>/g, '&gt;')
415
+ .replace(/@/g, '&#64;');
416
+ const escapedReport = escapeReport(report);
417
+ // Treat every scanner-rendered line as inert code. Even the Markdown
418
+ // formatter contains package-controlled text, which must not create
419
+ // links, images, headings, mentions, or other active PR content.
420
+ let reportForComment = escapedReport
421
+ .split(/\r?\n/)
422
+ .map((line) => ` ${line}`)
423
+ .join('\n');
424
+ if (reportReadTruncated && reportForComment) {
425
+ reportForComment += '\n\n> Report excerpt truncated before comment construction.';
151
426
  }
152
- const { data: comments } = await github.rest.issues.listComments({
427
+
428
+ const comments = await github.paginate(github.rest.issues.listComments, {
153
429
  owner: context.repo.owner,
154
430
  repo: context.repo.repo,
155
431
  issue_number: context.issue.number,
432
+ per_page: 100,
156
433
  });
157
434
 
158
- // Update existing comment or create new one
159
435
  const botComment = comments.find(
160
- (c) => c.body?.includes('supply-chain-guard Scan Report')
436
+ (comment) => comment.user?.login === 'github-actions[bot]'
437
+ && typeof comment.body === 'string'
438
+ && comment.body.startsWith(`${marker}\n`)
161
439
  );
440
+ const clean = reportValid && passesConsistent && !partial && findingsCount === 0 && scanOutcome === 'success';
162
441
 
163
- const body = report || '## 🛡️ supply-chain-guard Scan Report\n\n> No malicious indicators detected.';
442
+ // Do not add noise for a first clean scan, but replace a stale finding
443
+ // or partial comment when the repository returns to a complete clean state.
444
+ if (clean && !botComment) {
445
+ core.info('Complete clean scan; no existing scan comment to update.');
446
+ return;
447
+ }
448
+
449
+ const canonicalLine = findingsCount === null
450
+ ? ''
451
+ : `> Canonical JSON report: **${findingsCount} reportable finding(s)**.`;
452
+ let detail;
453
+ if (!reportValid) {
454
+ detail = `> WARNING: The scanner did not produce a validated report (step outcome: ${scanOutcome}). No clean verdict is available.`;
455
+ } else if (findingsCount === null) {
456
+ detail = '> WARNING: Scan metadata is unavailable. No clean verdict is available.';
457
+ } else if (!passesConsistent) {
458
+ detail = `> WARNING: The requested formatter or canonical JSON reconciliation failed. No clean verdict is available.\n\n${canonicalLine}`;
459
+ if (reportForComment) detail += `\n\n${reportForComment}`;
460
+ } else if (partial) {
461
+ detail = `> WARNING: Scan incomplete. Coverage is partial; no clean verdict is available.\n\n${canonicalLine}`;
462
+ if (reportForComment) detail += `\n\n${reportForComment}`;
463
+ } else if (findingsCount > 0) {
464
+ detail = `> WARNING: Findings were detected.\n\n${canonicalLine}`;
465
+ if (reportForComment) detail += `\n\n${reportForComment}`;
466
+ } else if (scanOutcome !== 'success') {
467
+ detail = `> WARNING: The scan step ended with ${scanOutcome} after producing a report. No clean verdict is available.\n\n${canonicalLine}`;
468
+ } else {
469
+ detail = `${canonicalLine}\n\n${reportForComment || '> Complete scan: no reportable malicious indicators detected.'}`;
470
+ }
471
+
472
+ const maxDetailChars = 55000;
473
+ if (detail.length > maxDetailChars) {
474
+ detail = detail.slice(0, maxDetailChars)
475
+ + '\n\n> Comment truncated. See the bounded job-log excerpt or consume the report-path output in a later step.';
476
+ }
477
+ const body = `${marker}\n\n${detail}`;
164
478
 
165
479
  if (botComment) {
166
480
  await github.rest.issues.updateComment({
@@ -1 +1 @@
1
- {"version":3,"file":"agentic-workflow-scanner.d.ts","sourceRoot":"","sources":["../src/agentic-workflow-scanner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAC;AAwDpD,6DAA6D;AAC7D,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,EAAE,CA8B3D"}
1
+ {"version":3,"file":"agentic-workflow-scanner.d.ts","sourceRoot":"","sources":["../src/agentic-workflow-scanner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAY,MAAM,YAAY,CAAC;AAyDpD,6DAA6D;AAC7D,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,EAAE,CA8B3D"}
@@ -49,6 +49,7 @@ const fs = __importStar(require("node:fs"));
49
49
  const path = __importStar(require("node:path"));
50
50
  const workflow_ast_js_1 = require("./workflow-ast.js");
51
51
  const patterns_js_1 = require("./patterns.js");
52
+ const pattern_scanner_js_1 = require("./pattern-scanner.js");
52
53
  /** Triggers that feed attacker-controllable text into the agent. */
53
54
  const UNTRUSTED_TRIGGERS = [
54
55
  "issues", "issue_comment", "pull_request", "pull_request_target",
@@ -152,17 +153,16 @@ function scanOne(frontmatter, body, rel, findings) {
152
153
  `(custom token secret or explicit repo list). Combined with a public-post tool this is the ` +
153
154
  `full GitLost lethal trifecta.`, rel, 1);
154
155
  }
155
- // LLM control tokens hidden in the instruction body.
156
- const bodyLines = body.split("\n");
157
- for (const p of patterns_js_1.PROMPT_INJECTION_PATTERNS) {
158
- const re = new RegExp(p.pattern, "i");
159
- for (let i = 0; i < bodyLines.length; i++) {
160
- if (re.test(bodyLines[i] ?? "")) {
161
- push(findings, "AGENTIC_WF_PROMPT_INJECTION", "high", `LLM control token / override phrase in an Agentic Workflow instruction body ` +
162
- `(matched ${p.rule}). Agents read the body verbatim.`, rel, i + 1);
163
- break; // one finding per pattern is enough
164
- }
165
- }
156
+ // LLM control tokens hidden in the instruction body. File/path gates target
157
+ // ordinary repository files and are intentionally irrelevant after this
158
+ // scanner has selected the agent-facing instruction body.
159
+ for (const pattern of patterns_js_1.PROMPT_INJECTION_PATTERNS) {
160
+ const hits = (0, pattern_scanner_js_1.matchPatternInSemanticText)(pattern, body, rel, findings, "i");
161
+ const hit = hits?.[0];
162
+ if (!hit)
163
+ continue;
164
+ push(findings, "AGENTIC_WF_PROMPT_INJECTION", "high", `LLM control token / override phrase in an Agentic Workflow instruction body ` +
165
+ `(matched ${pattern.rule}). Agents read the body verbatim.`, rel, hit.line);
166
166
  }
167
167
  }
168
168
  //# sourceMappingURL=agentic-workflow-scanner.js.map