remit-actions 1__py3-none-any.whl
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.
- remit/__init__.py +3 -0
- remit/__main__.py +3 -0
- remit/cli.py +101 -0
- remit/contexts.py +279 -0
- remit/expressions.py +7 -0
- remit/flows.py +483 -0
- remit/report.py +142 -0
- remit/sarif.py +74 -0
- remit/scan.py +644 -0
- remit/sources.py +223 -0
- remit/yaml_limits.py +52 -0
- remit_actions-1.dist-info/METADATA +260 -0
- remit_actions-1.dist-info/RECORD +17 -0
- remit_actions-1.dist-info/WHEEL +5 -0
- remit_actions-1.dist-info/entry_points.txt +2 -0
- remit_actions-1.dist-info/licenses/LICENSE +21 -0
- remit_actions-1.dist-info/top_level.txt +1 -0
remit/__init__.py
ADDED
remit/__main__.py
ADDED
remit/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .report import markdown
|
|
7
|
+
from .sarif import sarif
|
|
8
|
+
from .scan import audit, compare, error_report, permissions
|
|
9
|
+
from .sources import InputError, directory, git_snapshot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _outputs(parser):
|
|
13
|
+
parser.add_argument("--default-permissions", help='Trusted explicit JSON map, for example {"contents":"read"}')
|
|
14
|
+
parser.add_argument("--format", choices=("json", "markdown", "sarif"), default="json")
|
|
15
|
+
parser.add_argument("--output", type=Path)
|
|
16
|
+
parser.add_argument("--summary", type=Path, help="Explicit path to append a Markdown job summary")
|
|
17
|
+
parser.add_argument("--sarif", type=Path, help="Also write a SARIF 2.1.0 report to this path")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _defaults(value):
|
|
21
|
+
if value is None:
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
defaults = json.loads(value)
|
|
25
|
+
if not isinstance(defaults, dict):
|
|
26
|
+
raise ValueError("Explicit map required")
|
|
27
|
+
return permissions(defaults)
|
|
28
|
+
except (ValueError, TypeError) as exc:
|
|
29
|
+
raise InputError("Default permissions must be a supported explicit JSON permission map.") from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv=None) -> int:
|
|
33
|
+
parser = argparse.ArgumentParser(prog="remit")
|
|
34
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
35
|
+
|
|
36
|
+
scan = commands.add_parser("scan", help="Report what a change does to workflow privileges")
|
|
37
|
+
source = scan.add_mutually_exclusive_group(required=True)
|
|
38
|
+
source.add_argument("--dirs", nargs=2, metavar=("BASE_DIR", "HEAD_DIR"))
|
|
39
|
+
source.add_argument("--repo", help="Local Git repository; reads objects without checkout")
|
|
40
|
+
scan.add_argument("--base", help="Base ref, resolved and recorded as an exact commit")
|
|
41
|
+
scan.add_argument("--head", help="Head ref, resolved and recorded as an exact commit")
|
|
42
|
+
_outputs(scan)
|
|
43
|
+
|
|
44
|
+
check = commands.add_parser("audit", help="Report pull request content reachable from privileged workflows now "
|
|
45
|
+
"(WF010, WF011)")
|
|
46
|
+
target = check.add_mutually_exclusive_group(required=True)
|
|
47
|
+
target.add_argument("--dir", help="Directory containing .github/workflows")
|
|
48
|
+
target.add_argument("--repo", help="Local Git repository; reads objects without checkout")
|
|
49
|
+
check.add_argument("--ref", help="Commit or ref to audit with --repo (default: HEAD)")
|
|
50
|
+
check.add_argument("--fail-on-findings", action="store_true",
|
|
51
|
+
help="Exit 1 when the audit is complete and has findings")
|
|
52
|
+
_outputs(check)
|
|
53
|
+
|
|
54
|
+
args = parser.parse_args(argv)
|
|
55
|
+
if args.command == "scan":
|
|
56
|
+
if args.repo and (not args.base or not args.head):
|
|
57
|
+
parser.error("--repo requires --base and --head")
|
|
58
|
+
if args.dirs and (args.base or args.head):
|
|
59
|
+
parser.error("--base and --head are only used with --repo")
|
|
60
|
+
elif args.dir and args.ref:
|
|
61
|
+
parser.error("--ref is only used with --repo")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
defaults = _defaults(args.default_permissions)
|
|
65
|
+
if args.command == "audit":
|
|
66
|
+
snapshot = directory(args.dir) if args.dir else git_snapshot(args.repo, args.ref or "HEAD")
|
|
67
|
+
result = audit(snapshot, defaults)
|
|
68
|
+
else:
|
|
69
|
+
if args.dirs:
|
|
70
|
+
base, head = (directory(path) for path in args.dirs)
|
|
71
|
+
else:
|
|
72
|
+
base = git_snapshot(args.repo, args.base)
|
|
73
|
+
head = git_snapshot(args.repo, args.head)
|
|
74
|
+
result = compare(base, head, defaults)
|
|
75
|
+
except (InputError, OSError) as exc:
|
|
76
|
+
# Do not echo filesystem errors, source text, credentials, or git stderr.
|
|
77
|
+
reason = str(exc) if isinstance(exc, InputError) else "Could not read comparison inputs."
|
|
78
|
+
result = error_report(reason)
|
|
79
|
+
if args.command == "audit":
|
|
80
|
+
result["mode"] = "audit"
|
|
81
|
+
|
|
82
|
+
if args.format == "markdown":
|
|
83
|
+
text = markdown(result)
|
|
84
|
+
else:
|
|
85
|
+
text = json.dumps(sarif(result) if args.format == "sarif" else result, indent=2, sort_keys=True) + "\n"
|
|
86
|
+
try:
|
|
87
|
+
if args.output:
|
|
88
|
+
args.output.write_text(text, encoding="utf-8")
|
|
89
|
+
else:
|
|
90
|
+
sys.stdout.write(text)
|
|
91
|
+
if args.sarif:
|
|
92
|
+
args.sarif.write_text(json.dumps(sarif(result), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
93
|
+
if args.summary:
|
|
94
|
+
with args.summary.open("a", encoding="utf-8") as handle:
|
|
95
|
+
handle.write(markdown(result))
|
|
96
|
+
except OSError:
|
|
97
|
+
print("Could not write report output.", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
if result["analysis"] != "complete":
|
|
100
|
+
return 2
|
|
101
|
+
return 1 if args.command == "audit" and args.fail_on_findings and result["findings"] else 0
|
remit/contexts.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Configured runner/environment changes, without inventory or runtime claims."""
|
|
2
|
+
import re
|
|
3
|
+
from itertools import product
|
|
4
|
+
from .expressions import IDENTIFIER
|
|
5
|
+
|
|
6
|
+
# A deliberately explicit convention list, not discovery of runner ownership.
|
|
7
|
+
# Unknown/custom labels and groups merit review instead of being called self-hosted.
|
|
8
|
+
HOSTED_LABELS = set("""ubuntu-latest ubuntu-slim ubuntu-22.04 ubuntu-24.04 ubuntu-26.04
|
|
9
|
+
ubuntu-22.04-arm ubuntu-24.04-arm ubuntu-26.04-arm windows-latest windows-2022 windows-2025
|
|
10
|
+
windows-2025-vs2026 windows-11-arm windows-11-vs2026-arm macos-latest macos-13
|
|
11
|
+
macos-14 macos-15 macos-26 xcode-27
|
|
12
|
+
macos-15-intel macos-26-intel macos-14-large macos-14-xlarge macos-15-large
|
|
13
|
+
macos-15-xlarge macos-26-large macos-26-xlarge""".split())
|
|
14
|
+
MAX_MATRIX_ROWS = 256
|
|
15
|
+
MATRIX = re.compile(r"\s*\$\{\{\s*(?i:matrix)\.(" + IDENTIFIER + r")\s*\}\}\s*")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _matrix(job):
|
|
19
|
+
strategy = (job or {}).get("strategy", {})
|
|
20
|
+
return strategy.get("matrix") if isinstance(strategy, dict) else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_dynamic(value):
|
|
24
|
+
if isinstance(value, str):
|
|
25
|
+
return "${{" in value
|
|
26
|
+
if isinstance(value, dict):
|
|
27
|
+
return any(_is_dynamic(x) for x in value.values())
|
|
28
|
+
if isinstance(value, list):
|
|
29
|
+
return any(_is_dynamic(x) for x in value)
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _matrix_rows(matrix):
|
|
34
|
+
"""Expand a small literal matrix using GitHub's exclude/include ordering.
|
|
35
|
+
|
|
36
|
+
Returns None when the matrix is dynamic, malformed, ambiguous or too large.
|
|
37
|
+
"""
|
|
38
|
+
def fields(mapping):
|
|
39
|
+
# Matrix variable names are case-insensitive; reject case-only duplicates.
|
|
40
|
+
folded = {key.casefold(): value for key, value in mapping.items()}
|
|
41
|
+
return folded if len(folded) == len(mapping) else None
|
|
42
|
+
|
|
43
|
+
if _is_dynamic(matrix):
|
|
44
|
+
return None
|
|
45
|
+
axes = fields({k: v for k, v in matrix.items() if k not in ("include", "exclude")})
|
|
46
|
+
include, exclude = matrix.get("include", []), matrix.get("exclude", [])
|
|
47
|
+
if axes is None or any(not isinstance(v, list) or not v for v in axes.values()):
|
|
48
|
+
return None
|
|
49
|
+
for items in (include, exclude):
|
|
50
|
+
if not isinstance(items, list) or len(items) > MAX_MATRIX_ROWS:
|
|
51
|
+
return None
|
|
52
|
+
if any(not isinstance(x, dict) for x in items):
|
|
53
|
+
return None
|
|
54
|
+
include = [fields(item) for item in include]
|
|
55
|
+
exclude = [fields(item) for item in exclude]
|
|
56
|
+
if any(item is None for item in include + exclude):
|
|
57
|
+
return None
|
|
58
|
+
size = 1
|
|
59
|
+
for values in axes.values():
|
|
60
|
+
size *= len(values)
|
|
61
|
+
if size > MAX_MATRIX_ROWS:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
originals = [dict(zip(axes, values)) for values in product(*axes.values())] if axes else []
|
|
65
|
+
originals = [row for row in originals
|
|
66
|
+
if not any(all(k in row and row[k] == v for k, v in item.items()) for item in exclude)]
|
|
67
|
+
rows = [row.copy() for row in originals]
|
|
68
|
+
appended = []
|
|
69
|
+
for item in include:
|
|
70
|
+
# An include entry extends every original row it does not contradict;
|
|
71
|
+
# otherwise it becomes a new row.
|
|
72
|
+
matched = False
|
|
73
|
+
for original, row in zip(originals, rows):
|
|
74
|
+
if all(k not in original or original[k] == v for k, v in item.items()):
|
|
75
|
+
row.update(item)
|
|
76
|
+
matched = True
|
|
77
|
+
if not matched:
|
|
78
|
+
appended.append(item.copy())
|
|
79
|
+
rows += appended
|
|
80
|
+
return rows if len(rows) <= MAX_MATRIX_ROWS else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _options(value, job):
|
|
84
|
+
if not isinstance(value, str) or not value.strip():
|
|
85
|
+
raise ValueError("Runner/environment selectors must be nonempty strings.")
|
|
86
|
+
if "${{" not in value:
|
|
87
|
+
return [value], False
|
|
88
|
+
match = MATRIX.fullmatch(value)
|
|
89
|
+
matrix = _matrix(job)
|
|
90
|
+
if not match or not isinstance(matrix, dict):
|
|
91
|
+
return [], True
|
|
92
|
+
field = match.group(1).casefold()
|
|
93
|
+
rows = _matrix_rows(matrix)
|
|
94
|
+
if not rows or any(field not in row for row in rows):
|
|
95
|
+
return [], True
|
|
96
|
+
values = [row[field] for row in rows]
|
|
97
|
+
if not values or any(not isinstance(x, str) or not x.strip() or "${{" in x for x in values):
|
|
98
|
+
return [], True
|
|
99
|
+
return values, False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _runner(job):
|
|
103
|
+
if not job or "runs-on" not in job:
|
|
104
|
+
return {"labels": [], "groups": []}, False
|
|
105
|
+
value = job["runs-on"]
|
|
106
|
+
groups = []
|
|
107
|
+
unknown = False
|
|
108
|
+
if isinstance(value, dict):
|
|
109
|
+
if not value or set(value) - {"group", "labels"}:
|
|
110
|
+
raise ValueError("Unsupported runs-on mapping.")
|
|
111
|
+
if "group" in value:
|
|
112
|
+
groups, unknown = _options(value["group"], job)
|
|
113
|
+
value = value.get("labels", [])
|
|
114
|
+
if isinstance(value, str):
|
|
115
|
+
value = [value]
|
|
116
|
+
if not isinstance(value, list):
|
|
117
|
+
raise ValueError("Unsupported runs-on declaration.")
|
|
118
|
+
if not value and not groups and not unknown:
|
|
119
|
+
raise ValueError("Empty runs-on selection.")
|
|
120
|
+
labels = []
|
|
121
|
+
for label in value:
|
|
122
|
+
options, unresolved = _options(label, job)
|
|
123
|
+
labels.extend(x.casefold() for x in options)
|
|
124
|
+
unknown |= unresolved
|
|
125
|
+
return {"labels": sorted(set(labels)), "groups": sorted(set(groups))}, unknown
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _environment(job):
|
|
129
|
+
if not job or "environment" not in job:
|
|
130
|
+
return {"names": []}, False
|
|
131
|
+
value = job["environment"]
|
|
132
|
+
if isinstance(value, dict):
|
|
133
|
+
if "name" not in value:
|
|
134
|
+
raise ValueError("Environment mapping needs a name.")
|
|
135
|
+
value = value["name"]
|
|
136
|
+
names, unknown = _options(value, job)
|
|
137
|
+
# Environment names are case insensitive. URL-only edits do not change context.
|
|
138
|
+
return {"names": sorted({x.casefold() for x in names})}, unknown
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _runner_targets(job, *, include_environment=False):
|
|
142
|
+
"""Keep resolved selectors from each matrix row together, never cross-join unions."""
|
|
143
|
+
if not job or "runs-on" not in job:
|
|
144
|
+
return []
|
|
145
|
+
value = job["runs-on"]
|
|
146
|
+
group = value.get("group") if isinstance(value, dict) else None
|
|
147
|
+
labels = value.get("labels", []) if isinstance(value, dict) else value
|
|
148
|
+
if isinstance(labels, str):
|
|
149
|
+
labels = [labels]
|
|
150
|
+
environment = job.get("environment") if include_environment else None
|
|
151
|
+
if isinstance(environment, dict):
|
|
152
|
+
environment = environment.get("name")
|
|
153
|
+
selectors = labels + ([group] if group is not None else []) + ([environment] if environment is not None else [])
|
|
154
|
+
rows = _matrix_rows(_matrix(job)) if any(MATRIX.fullmatch(x) for x in selectors) else [{}]
|
|
155
|
+
# Called only after the corresponding resolvers established static values.
|
|
156
|
+
if not rows:
|
|
157
|
+
raise ValueError("Runner target combinations cannot be resolved.")
|
|
158
|
+
|
|
159
|
+
def selected(text, row):
|
|
160
|
+
match = MATRIX.fullmatch(text)
|
|
161
|
+
return row[match.group(1).casefold()] if match else text
|
|
162
|
+
|
|
163
|
+
targets = set()
|
|
164
|
+
for row in rows:
|
|
165
|
+
label_set = tuple(sorted({selected(label, row).casefold() for label in labels}))
|
|
166
|
+
groups = (selected(group, row),) if group is not None else ()
|
|
167
|
+
name = selected(environment, row).casefold() if environment is not None else None
|
|
168
|
+
targets.add((label_set, groups, name))
|
|
169
|
+
return [{"labels": list(labels), "groups": list(groups),
|
|
170
|
+
**({"environment": name} if include_environment else {})}
|
|
171
|
+
for labels, groups, name in sorted(targets)]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _context_selection(job):
|
|
175
|
+
def selector(value, *, fold=False):
|
|
176
|
+
match = MATRIX.fullmatch(value)
|
|
177
|
+
if match:
|
|
178
|
+
return "matrix", match.group(1).casefold()
|
|
179
|
+
if "${{" in value:
|
|
180
|
+
return "expression", value
|
|
181
|
+
return "literal", value.casefold() if fold else value
|
|
182
|
+
|
|
183
|
+
runner = job.get("runs-on")
|
|
184
|
+
group = runner.get("group") if isinstance(runner, dict) else None
|
|
185
|
+
labels = runner.get("labels", []) if isinstance(runner, dict) else runner
|
|
186
|
+
if isinstance(labels, str):
|
|
187
|
+
labels = [labels]
|
|
188
|
+
# Normalize syntax without erasing which matrix field each row selects.
|
|
189
|
+
runner = (tuple(sorted({selector(label, fold=True) for label in labels})),
|
|
190
|
+
selector(group) if group is not None else None)
|
|
191
|
+
environment = job.get("environment")
|
|
192
|
+
if isinstance(environment, dict):
|
|
193
|
+
environment = environment.get("name")
|
|
194
|
+
return runner, selector(environment, fold=True), _matrix(job)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _compare_associations(before, after, unresolved):
|
|
198
|
+
"""Close the equal-marginals gap; individual context changes have their own rules."""
|
|
199
|
+
if not before or not all(field in job for job in (before, after) for field in ("runs-on", "environment")):
|
|
200
|
+
return [], []
|
|
201
|
+
if _context_selection(before) == _context_selection(after):
|
|
202
|
+
return [], []
|
|
203
|
+
if not unresolved["runs-on"] and _runner_targets(before) != _runner_targets(after):
|
|
204
|
+
return [], []
|
|
205
|
+
if any(unresolved.values()):
|
|
206
|
+
return [], [("incomplete", "Changed runner/environment associations cannot be fully resolved; "
|
|
207
|
+
"only direct static matrix fields are supported.")]
|
|
208
|
+
old = _runner_targets(before, include_environment=True)
|
|
209
|
+
new = _runner_targets(after, include_environment=True)
|
|
210
|
+
if not any(pair not in old for pair in new):
|
|
211
|
+
return [], []
|
|
212
|
+
return [{"rule": "WF008", "before": {"pairs": old}, "after": {"pairs": new},
|
|
213
|
+
"message": "New runner/environment pairing introduced while individual target sets stay unchanged. "
|
|
214
|
+
"Review the paired runner and environment-scoped settings; actual access and protection "
|
|
215
|
+
"policies are not verified."}], []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def compare_contexts(before, after):
|
|
219
|
+
findings, diagnostics, assumptions = [], [], []
|
|
220
|
+
unresolved = {}
|
|
221
|
+
for field, rule, resolve in (("runs-on", "WF005", _runner), ("environment", "WF007", _environment)):
|
|
222
|
+
try:
|
|
223
|
+
old, old_unknown = resolve(before)
|
|
224
|
+
new, new_unknown = resolve(after)
|
|
225
|
+
except ValueError as exc:
|
|
226
|
+
diagnostics.append(("error", str(exc)))
|
|
227
|
+
continue
|
|
228
|
+
unresolved[field] = old_unknown or new_unknown
|
|
229
|
+
old_selector, new_selector = (before or {}).get(field), after.get(field)
|
|
230
|
+
if field == "environment":
|
|
231
|
+
if isinstance(old_selector, dict):
|
|
232
|
+
old_selector = old_selector.get("name")
|
|
233
|
+
if isinstance(new_selector, dict):
|
|
234
|
+
new_selector = new_selector.get("name")
|
|
235
|
+
if old_unknown or new_unknown:
|
|
236
|
+
if old_selector == new_selector and _matrix(before) == _matrix(after):
|
|
237
|
+
assumptions.append(f"Unchanged unresolved {field} selector/matrix compares symbolically; "
|
|
238
|
+
"external runtime context is assumed fixed and is not observed.")
|
|
239
|
+
continue
|
|
240
|
+
diagnostics.append(("incomplete", f"Changed {field} selection cannot be fully resolved; "
|
|
241
|
+
"only direct static matrix fields are supported."))
|
|
242
|
+
if old_selector == new_selector:
|
|
243
|
+
# A matrix/context edit does not prove that the unchanged
|
|
244
|
+
# unresolved selector targets a different resource.
|
|
245
|
+
continue
|
|
246
|
+
old = {**old, "unresolved": old_unknown}
|
|
247
|
+
new = {**new, "unresolved": new_unknown}
|
|
248
|
+
elif old == new:
|
|
249
|
+
if field != "runs-on":
|
|
250
|
+
continue
|
|
251
|
+
if old_selector == new_selector and _matrix(before) == _matrix(after):
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
old_targets, new_targets = _runner_targets(before), _runner_targets(after)
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
diagnostics.append(("error", str(exc)))
|
|
257
|
+
continue
|
|
258
|
+
if old_targets == new_targets:
|
|
259
|
+
continue
|
|
260
|
+
# Equal unions can hide changed label/group combinations. Include
|
|
261
|
+
# row targets so the finding explains the actual configured change.
|
|
262
|
+
old, new = {**old, "targets": old_targets}, {**new, "targets": new_targets}
|
|
263
|
+
if field == "runs-on":
|
|
264
|
+
if not (old_unknown or new_unknown) and not (new["groups"] or set(new["labels"]) - HOSTED_LABELS):
|
|
265
|
+
continue
|
|
266
|
+
message = ("Runner selection changed to a self-hosted, custom/group, or unresolved context. "
|
|
267
|
+
"Review infrastructure access; ownership and execution are not verified.")
|
|
268
|
+
else:
|
|
269
|
+
message = ("Environment selection changed. Review scoped configuration and approval requirements; "
|
|
270
|
+
"secret availability and protection policies are not verified.")
|
|
271
|
+
findings.append({"rule": rule, "before": old, "after": new, "message": message})
|
|
272
|
+
if not findings and not diagnostics:
|
|
273
|
+
try:
|
|
274
|
+
changes, issues = _compare_associations(before, after, unresolved)
|
|
275
|
+
findings.extend(changes)
|
|
276
|
+
diagnostics.extend(issues)
|
|
277
|
+
except ValueError as exc:
|
|
278
|
+
diagnostics.append(("error", str(exc)))
|
|
279
|
+
return findings, diagnostics, assumptions
|
remit/expressions.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Shared lexical boundary for supported GitHub expression property names.
|
|
2
|
+
|
|
3
|
+
This is not an expression evaluator. Hyphens belong to identifiers, so a
|
|
4
|
+
property such as inputs.use-secrets must never become a secrets context read.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_-]*"
|