@metasession.co/devaudit-cli 0.3.27 → 0.3.29
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.
- package/dist/index.js +152 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/sdlc/files/_common/scripts/close-out-release.sh +10 -13
- package/sdlc/files/_common/scripts/evaluate-npm-audit.sh +103 -66
- package/sdlc/files/_common/scripts/evaluate-npm-audit.test.sh +108 -3
- package/sdlc/files/_common/scripts/markdown-table.sh +126 -0
- package/sdlc/files/_common/scripts/markdown-table.test.sh +36 -0
- package/sdlc/files/_common/scripts/validate-compliance-artifacts.sh +15 -6
- package/sdlc/files/_common/skills/sdlc-implementer/SKILL.md +1 -1
- package/sdlc/files/ci/check-release-approval.yml.template +4 -0
- package/sdlc/files/ci/ci.yml.template +10 -2
- package/sdlc/files/ci/close-out-release.yml.template +3 -0
- package/sdlc/files/ci/compliance-evidence.yml.template +8 -5
- package/sdlc/files/ci/feature-e2e.yml.template +3 -2
- package/sdlc/files/ci/post-deploy-prod.yml.template +3 -0
- package/sdlc/files/ci/python/ci.yml.template +8 -5
- package/sdlc/files/ci/reconcile-deployment.yml.template +3 -0
- package/sdlc/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metasession.co/devaudit-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.29",
|
|
4
4
|
"description": "DevAudit CLI — installs, syncs, and operates the Metasession SDLC across consumer projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@clack/prompts": "^0.8.2",
|
|
36
|
-
"@metasession.co/devaudit-plugin-sdk": "^0.3.
|
|
36
|
+
"@metasession.co/devaudit-plugin-sdk": "^0.3.29",
|
|
37
37
|
"ajv": "^8.20.0",
|
|
38
38
|
"commander": "^12.1.0",
|
|
39
39
|
"consola": "^3.2.3",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
|
|
35
35
|
set -euo pipefail
|
|
36
36
|
|
|
37
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
38
|
+
# shellcheck source=markdown-table.sh
|
|
39
|
+
source "$SCRIPT_DIR/markdown-table.sh"
|
|
40
|
+
|
|
37
41
|
REQ_ID="${1:-}"
|
|
38
42
|
RELEASE_PR=""
|
|
39
43
|
shift || true
|
|
@@ -89,21 +93,14 @@ find_release_ticket_file() {
|
|
|
89
93
|
update_rtm_status() {
|
|
90
94
|
local req_id="$1"
|
|
91
95
|
local target_status="$2"
|
|
92
|
-
local tmp_file
|
|
96
|
+
local tmp_file status_col
|
|
93
97
|
tmp_file="$(mktemp)"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
status_col="$(markdown_table_column_index_for_row \
|
|
99
|
+
"$RTM" "$req_id" Status REQ-ID/ID Status 2>/dev/null || true)"
|
|
100
|
+
if [ -n "$status_col" ]; then
|
|
101
|
+
awk -v req="$req_id" -v target_status="$target_status" -v statuscol="$status_col" '
|
|
102
|
+
BEGIN { FS="|"; OFS="|" }
|
|
97
103
|
/\\\|/ { gsub(/\\\|/, "\001", $0) }
|
|
98
|
-
{
|
|
99
|
-
cand=0; idseen=0
|
|
100
|
-
for (i=1; i<=NF; i++) {
|
|
101
|
-
c=$i; gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
|
|
102
|
-
if (c=="Status") cand=i
|
|
103
|
-
if (c=="ID" || c=="REQ-ID" || c=="REQ ID" || c ~ /^Requirement/) idseen=1
|
|
104
|
-
}
|
|
105
|
-
if (cand>0 && idseen) statuscol=cand
|
|
106
|
-
}
|
|
107
104
|
$0 ~ ("^\\| " req " ") && statuscol>0 {
|
|
108
105
|
cell=$statuscol
|
|
109
106
|
note=""
|
|
@@ -39,6 +39,7 @@ done
|
|
|
39
39
|
python3 - "$AUDIT_PATH" "$LOCK_PATH" "$EXCEPTIONS_PATH" "$OUTPUT_PATH" <<'PY'
|
|
40
40
|
import json
|
|
41
41
|
import os
|
|
42
|
+
import re
|
|
42
43
|
import sys
|
|
43
44
|
from datetime import date
|
|
44
45
|
|
|
@@ -144,8 +145,11 @@ for index, item in enumerate(exceptions, start=1):
|
|
|
144
145
|
fail(f"exception {index} approvedAt cannot be in the future")
|
|
145
146
|
if expires_at <= today:
|
|
146
147
|
fail(f"exception {index} is expired on {item['expiresAt']}")
|
|
147
|
-
if
|
|
148
|
-
fail(f"exception {index} remediationIssue must be
|
|
148
|
+
if re.fullmatch(r"https://github\.com/[^/]+/[^/]+/issues/[1-9][0-9]*", item["remediationIssue"]) is None:
|
|
149
|
+
fail(f"exception {index} remediationIssue must be an exact GitHub issue URL")
|
|
150
|
+
controls = item.get("compensatingControls")
|
|
151
|
+
if controls is not None and (not isinstance(controls, str) or not controls.strip()):
|
|
152
|
+
fail(f"exception {index} compensatingControls must be a non-empty string when present")
|
|
149
153
|
key = (item["advisoryId"], item["dependencyPath"])
|
|
150
154
|
if key in seen_exception_keys:
|
|
151
155
|
fail(f"duplicate exception for {item['advisoryId']} at {item['dependencyPath']}")
|
|
@@ -157,75 +161,108 @@ accepted = []
|
|
|
157
161
|
unresolved = []
|
|
158
162
|
seen_advisories = set()
|
|
159
163
|
|
|
160
|
-
|
|
164
|
+
vulnerabilities = audit["vulnerabilities"]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def concrete_advisories(finding_name, trail=()):
|
|
168
|
+
if finding_name in trail:
|
|
169
|
+
fail(f"audit vulnerability graph contains a cycle: {' -> '.join((*trail, finding_name))}")
|
|
170
|
+
finding = vulnerabilities.get(finding_name)
|
|
161
171
|
if not isinstance(finding, dict):
|
|
162
|
-
fail(f"audit vulnerability {finding_name}
|
|
172
|
+
fail(f"audit vulnerability {finding_name} is missing or is not an object")
|
|
163
173
|
via = finding.get("via", [])
|
|
164
174
|
if not isinstance(via, list):
|
|
165
175
|
fail(f"audit vulnerability {finding_name} has invalid via data")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
176
|
+
|
|
177
|
+
resolved = []
|
|
178
|
+
for index, item in enumerate(via):
|
|
179
|
+
if isinstance(item, str):
|
|
180
|
+
if item not in vulnerabilities:
|
|
181
|
+
fail(f"audit vulnerability {finding_name} references missing vulnerability {item}")
|
|
182
|
+
resolved.extend(concrete_advisories(item, (*trail, finding_name)))
|
|
183
|
+
elif isinstance(item, dict):
|
|
184
|
+
if item.get("severity") in ("high", "critical"):
|
|
185
|
+
resolved.append((finding_name, index, item))
|
|
186
|
+
else:
|
|
187
|
+
fail(f"audit vulnerability {finding_name} has invalid via entry")
|
|
188
|
+
return resolved
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
advisory_sources = {}
|
|
192
|
+
for finding_name, finding in vulnerabilities.items():
|
|
193
|
+
if not isinstance(finding, dict):
|
|
194
|
+
fail(f"audit vulnerability {finding_name} must be an object")
|
|
195
|
+
resolved = concrete_advisories(finding_name)
|
|
196
|
+
if finding.get("severity") in ("high", "critical") and not resolved:
|
|
197
|
+
fail(f"high/critical audit vulnerability {finding_name} has no reachable advisory object")
|
|
198
|
+
for source_name, source_index, advisory in resolved:
|
|
199
|
+
advisory_sources.setdefault((source_name, source_index), {
|
|
200
|
+
"advisory": advisory,
|
|
201
|
+
"affectedFindings": set(),
|
|
202
|
+
})["affectedFindings"].add(finding_name)
|
|
203
|
+
|
|
204
|
+
for (source_name, _source_index), source in advisory_sources.items():
|
|
205
|
+
advisory = source["advisory"]
|
|
206
|
+
source_finding = vulnerabilities[source_name]
|
|
207
|
+
nodes = source_finding.get("nodes", [])
|
|
208
|
+
if not isinstance(nodes, list) or not nodes:
|
|
209
|
+
fail(f"audit vulnerability {source_name} has no installed package-lock node")
|
|
210
|
+
|
|
211
|
+
package = advisory.get("dependency") or source_name
|
|
212
|
+
advisory_range = advisory.get("range")
|
|
213
|
+
if not isinstance(package, str) or not package:
|
|
214
|
+
fail(f"advisory in {source_name} has no package name")
|
|
215
|
+
if not isinstance(advisory_range, str) or not advisory_range:
|
|
216
|
+
fail(f"advisory {advisory_id(advisory)} has no vulnerable range")
|
|
217
|
+
identifier = advisory_id(advisory)
|
|
218
|
+
for node_path in nodes:
|
|
219
|
+
if not isinstance(node_path, str):
|
|
220
|
+
fail(f"advisory {identifier} has invalid package-lock node")
|
|
221
|
+
node = packages.get(node_path)
|
|
222
|
+
if not isinstance(node, dict) or not isinstance(node.get("version"), str):
|
|
223
|
+
fail(f"cannot resolve installed version for {node_path}")
|
|
224
|
+
if package_name_from_path(node_path) != package:
|
|
225
|
+
fail(f"audit package {package} does not match package-lock node {node_path}")
|
|
226
|
+
version = node["version"]
|
|
227
|
+
introduced_by = introducer_for(node_path, packages)
|
|
228
|
+
decision_key = (identifier, node_path)
|
|
229
|
+
if decision_key in seen_advisories:
|
|
230
|
+
continue
|
|
231
|
+
seen_advisories.add(decision_key)
|
|
232
|
+
context = {
|
|
233
|
+
"advisoryId": identifier,
|
|
234
|
+
"severity": advisory["severity"],
|
|
235
|
+
"package": package,
|
|
236
|
+
"vulnerableRange": advisory_range,
|
|
237
|
+
"vulnerableVersion": version,
|
|
238
|
+
"dependencyPath": node_path,
|
|
239
|
+
"introducedBy": introduced_by,
|
|
240
|
+
"affectedFindings": sorted(source["affectedFindings"]),
|
|
241
|
+
}
|
|
242
|
+
match = next((item for item in validated_exceptions if all(
|
|
243
|
+
item[field] == context[field]
|
|
244
|
+
for field in ("advisoryId", "package", "vulnerableRange", "vulnerableVersion", "dependencyPath", "introducedBy")
|
|
245
|
+
)), None)
|
|
246
|
+
if match is None:
|
|
247
|
+
unresolved.append(context)
|
|
248
|
+
print(
|
|
249
|
+
f"Unaccepted {context['severity']} risk: {identifier} "
|
|
250
|
+
f"({package}@{version} at {node_path})",
|
|
251
|
+
file=sys.stderr,
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
acceptance = {
|
|
255
|
+
field: match[field]
|
|
256
|
+
for field in ("approvedAt", "expiresAt", "approvedBy", "reason", "remediationIssue")
|
|
207
257
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
f"({package}@{version} at {node_path})",
|
|
217
|
-
file=sys.stderr,
|
|
218
|
-
)
|
|
219
|
-
else:
|
|
220
|
-
accepted.append({**context, "acceptance": {
|
|
221
|
-
field: match[field]
|
|
222
|
-
for field in ("approvedAt", "expiresAt", "approvedBy", "reason", "remediationIssue")
|
|
223
|
-
}})
|
|
224
|
-
print(
|
|
225
|
-
f"Accepted temporary risk: {identifier} ({package}@{version}) "
|
|
226
|
-
f"until {match['expiresAt']} by {match['approvedBy']}; "
|
|
227
|
-
f"remediation {match['remediationIssue']}",
|
|
228
|
-
)
|
|
258
|
+
if "compensatingControls" in match:
|
|
259
|
+
acceptance["compensatingControls"] = match["compensatingControls"]
|
|
260
|
+
accepted.append({**context, "acceptance": acceptance})
|
|
261
|
+
print(
|
|
262
|
+
f"Accepted temporary risk: {identifier} ({package}@{version}) "
|
|
263
|
+
f"until {match['expiresAt']} by {match['approvedBy']}; "
|
|
264
|
+
f"remediation {match['remediationIssue']}",
|
|
265
|
+
)
|
|
229
266
|
|
|
230
267
|
result = {
|
|
231
268
|
"schemaVersion": 1,
|
|
@@ -7,6 +7,9 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
7
7
|
HELPER="$SCRIPT_DIR/evaluate-npm-audit.sh"
|
|
8
8
|
WORK="$(mktemp -d)"
|
|
9
9
|
trap 'rm -rf "$WORK"' EXIT
|
|
10
|
+
APPROVED_AT="$(date -u -d 'yesterday' +%Y-%m-%d)"
|
|
11
|
+
EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%d)"
|
|
12
|
+
EXPIRED_AT="$(date -u -d 'yesterday' +%Y-%m-%d)"
|
|
10
13
|
|
|
11
14
|
mkdir -p "$WORK/compliance/security"
|
|
12
15
|
|
|
@@ -37,6 +40,47 @@ write_empty_audit() {
|
|
|
37
40
|
printf '{"vulnerabilities":{}}\n' > "$WORK/dependency-audit.json"
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
write_meta_audit() {
|
|
44
|
+
cat > "$WORK/dependency-audit.json" <<'JSON'
|
|
45
|
+
{
|
|
46
|
+
"vulnerabilities": {
|
|
47
|
+
"eslint": {
|
|
48
|
+
"name": "eslint",
|
|
49
|
+
"severity": "high",
|
|
50
|
+
"via": ["@eslint/eslintrc"],
|
|
51
|
+
"nodes": ["node_modules/eslint"]
|
|
52
|
+
},
|
|
53
|
+
"@eslint/eslintrc": {
|
|
54
|
+
"name": "@eslint/eslintrc",
|
|
55
|
+
"severity": "high",
|
|
56
|
+
"via": ["minimatch"],
|
|
57
|
+
"nodes": ["node_modules/@eslint/eslintrc"]
|
|
58
|
+
},
|
|
59
|
+
"minimatch": {
|
|
60
|
+
"name": "minimatch",
|
|
61
|
+
"severity": "high",
|
|
62
|
+
"via": ["brace-expansion"],
|
|
63
|
+
"nodes": ["node_modules/@eslint/eslintrc/node_modules/minimatch"]
|
|
64
|
+
},
|
|
65
|
+
"brace-expansion": {
|
|
66
|
+
"name": "brace-expansion",
|
|
67
|
+
"severity": "high",
|
|
68
|
+
"via": [
|
|
69
|
+
{
|
|
70
|
+
"name": "brace-expansion",
|
|
71
|
+
"dependency": "brace-expansion",
|
|
72
|
+
"url": "https://github.com/advisories/GHSA-meta-chain",
|
|
73
|
+
"severity": "high",
|
|
74
|
+
"range": "<=5.0.7"
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
"nodes": ["node_modules/@eslint/eslintrc/node_modules/brace-expansion"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
JSON
|
|
82
|
+
}
|
|
83
|
+
|
|
40
84
|
write_lock() {
|
|
41
85
|
cat > "$WORK/package-lock.json" <<'JSON'
|
|
42
86
|
{
|
|
@@ -50,6 +94,21 @@ write_lock() {
|
|
|
50
94
|
JSON
|
|
51
95
|
}
|
|
52
96
|
|
|
97
|
+
write_meta_lock() {
|
|
98
|
+
cat > "$WORK/package-lock.json" <<'JSON'
|
|
99
|
+
{
|
|
100
|
+
"lockfileVersion": 3,
|
|
101
|
+
"packages": {
|
|
102
|
+
"": {"name": "fixture"},
|
|
103
|
+
"node_modules/eslint": {"version": "9.0.0"},
|
|
104
|
+
"node_modules/@eslint/eslintrc": {"version": "3.0.0"},
|
|
105
|
+
"node_modules/@eslint/eslintrc/node_modules/minimatch": {"version": "9.0.0"},
|
|
106
|
+
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {"version": "2.0.1"}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
JSON
|
|
110
|
+
}
|
|
111
|
+
|
|
53
112
|
write_exception() {
|
|
54
113
|
cat > "$WORK/compliance/security/accepted-vulnerabilities.json" <<'JSON'
|
|
55
114
|
{
|
|
@@ -66,11 +125,16 @@ write_exception() {
|
|
|
66
125
|
"expiresAt": "2026-12-31",
|
|
67
126
|
"approvedBy": "reviewer@example.test",
|
|
68
127
|
"reason": "Fixture acceptance while the introducing dependency awaits an upstream repair.",
|
|
69
|
-
"remediationIssue": "https://github.com/example/repo/issues/1"
|
|
128
|
+
"remediationIssue": "https://github.com/example/repo/issues/1",
|
|
129
|
+
"compensatingControls": "Monitor upstream releases and retain the unaffected root package."
|
|
70
130
|
}
|
|
71
131
|
]
|
|
72
132
|
}
|
|
73
133
|
JSON
|
|
134
|
+
jq --arg approved "$APPROVED_AT" --arg expires "$EXPIRES_AT" \
|
|
135
|
+
'.exceptions[0].approvedAt = $approved | .exceptions[0].expiresAt = $expires' \
|
|
136
|
+
"$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
137
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
74
138
|
}
|
|
75
139
|
|
|
76
140
|
run_helper() {
|
|
@@ -107,7 +171,7 @@ jq -e '.summary.accepted == 0 and .summary.unresolved == 0' "$WORK/dependency-ri
|
|
|
107
171
|
write_audit
|
|
108
172
|
write_exception
|
|
109
173
|
expect_success
|
|
110
|
-
jq -e '.summary.accepted == 1 and .summary.unresolved == 0 and .accepted[0].acceptance.remediationIssue == "https://github.com/example/repo/issues/1"' \
|
|
174
|
+
jq -e '.summary.accepted == 1 and .summary.unresolved == 0 and .accepted[0].acceptance.remediationIssue == "https://github.com/example/repo/issues/1" and (.accepted[0].acceptance.compensatingControls | length > 0)' \
|
|
111
175
|
"$WORK/dependency-risk-evaluation.json" >/dev/null
|
|
112
176
|
|
|
113
177
|
jq '.exceptions[0].advisoryId = "GHSA-wrong"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
@@ -140,7 +204,7 @@ expect_failure
|
|
|
140
204
|
|
|
141
205
|
write_exception
|
|
142
206
|
write_audit
|
|
143
|
-
jq '.exceptions[0].expiresAt =
|
|
207
|
+
jq --arg expired "$EXPIRED_AT" '.exceptions[0].expiresAt = $expired' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
144
208
|
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
145
209
|
expect_failure
|
|
146
210
|
|
|
@@ -150,6 +214,18 @@ jq '.exceptions[0].expiresAt = "not-a-date"' "$WORK/compliance/security/accepted
|
|
|
150
214
|
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
151
215
|
expect_failure
|
|
152
216
|
|
|
217
|
+
write_exception
|
|
218
|
+
write_audit
|
|
219
|
+
jq '.exceptions[0].remediationIssue = "https://github.com/example/repo"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
220
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
221
|
+
expect_failure
|
|
222
|
+
|
|
223
|
+
write_exception
|
|
224
|
+
write_audit
|
|
225
|
+
jq '.exceptions[0].compensatingControls = []' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
226
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
227
|
+
expect_failure
|
|
228
|
+
|
|
153
229
|
write_exception
|
|
154
230
|
write_audit
|
|
155
231
|
jq 'del(.exceptions[0].approvedAt)' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
@@ -173,4 +249,33 @@ write_exception
|
|
|
173
249
|
printf '{not-json}\n' > "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
174
250
|
expect_failure
|
|
175
251
|
|
|
252
|
+
write_meta_audit
|
|
253
|
+
write_meta_lock
|
|
254
|
+
rm -f "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
255
|
+
expect_failure
|
|
256
|
+
jq -e '
|
|
257
|
+
.summary.unresolved == 1 and
|
|
258
|
+
.unresolved[0].advisoryId == "GHSA-meta-chain" and
|
|
259
|
+
.unresolved[0].dependencyPath == "node_modules/@eslint/eslintrc/node_modules/brace-expansion" and
|
|
260
|
+
.unresolved[0].affectedFindings == ["@eslint/eslintrc", "brace-expansion", "eslint", "minimatch"]
|
|
261
|
+
' "$WORK/dependency-risk-evaluation.json" >/dev/null
|
|
262
|
+
|
|
263
|
+
write_meta_audit
|
|
264
|
+
jq '.vulnerabilities.minimatch.via = ["missing-package"]' \
|
|
265
|
+
"$WORK/dependency-audit.json" > "$WORK/changed.json"
|
|
266
|
+
mv "$WORK/changed.json" "$WORK/dependency-audit.json"
|
|
267
|
+
expect_failure
|
|
268
|
+
|
|
269
|
+
write_meta_audit
|
|
270
|
+
jq '.vulnerabilities["brace-expansion"].via = ["eslint"]' \
|
|
271
|
+
"$WORK/dependency-audit.json" > "$WORK/changed.json"
|
|
272
|
+
mv "$WORK/changed.json" "$WORK/dependency-audit.json"
|
|
273
|
+
expect_failure
|
|
274
|
+
|
|
275
|
+
write_meta_audit
|
|
276
|
+
jq '.vulnerabilities["brace-expansion"].via = []' \
|
|
277
|
+
"$WORK/dependency-audit.json" > "$WORK/changed.json"
|
|
278
|
+
mv "$WORK/changed.json" "$WORK/dependency-audit.json"
|
|
279
|
+
expect_failure
|
|
280
|
+
|
|
176
281
|
echo "evaluate-npm-audit: PASS"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Context-aware helpers for simple GitHub-flavoured Markdown tables.
|
|
3
|
+
|
|
4
|
+
markdown_table_cell() {
|
|
5
|
+
local file="$1"
|
|
6
|
+
local key_value="$2"
|
|
7
|
+
local target_header="$3"
|
|
8
|
+
shift 3
|
|
9
|
+
local required_headers=("$@")
|
|
10
|
+
|
|
11
|
+
[ -f "$file" ] || return 1
|
|
12
|
+
awk \
|
|
13
|
+
-v key_value="$key_value" \
|
|
14
|
+
-v target_header="$target_header" \
|
|
15
|
+
-v required_headers="$(IFS=,; printf '%s' "${required_headers[*]}")" '
|
|
16
|
+
function clean(value) {
|
|
17
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
|
18
|
+
gsub(/\001/, "\\|", value)
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
function split_row(line, cells, protected) {
|
|
22
|
+
protected=line
|
|
23
|
+
gsub(/\\\|/, "\001", protected)
|
|
24
|
+
return split(protected, cells, "|")
|
|
25
|
+
}
|
|
26
|
+
function header_matches(name, required, options, count, i) {
|
|
27
|
+
count=split(required, options, "/")
|
|
28
|
+
for (i=1; i<=count; i++) {
|
|
29
|
+
if (tolower(name) == tolower(options[i])) return 1
|
|
30
|
+
}
|
|
31
|
+
return 0
|
|
32
|
+
}
|
|
33
|
+
BEGIN {
|
|
34
|
+
required_count=split(required_headers, required, ",")
|
|
35
|
+
active=0
|
|
36
|
+
}
|
|
37
|
+
/^\|/ {
|
|
38
|
+
count=split_row($0, cells)
|
|
39
|
+
candidate_target=0
|
|
40
|
+
candidate_key=0
|
|
41
|
+
matched=0
|
|
42
|
+
for (i=2; i<count; i++) {
|
|
43
|
+
name=clean(cells[i])
|
|
44
|
+
if (tolower(name) == tolower(target_header)) candidate_target=i
|
|
45
|
+
for (r=1; r<=required_count; r++) {
|
|
46
|
+
if (header_matches(name, required[r])) matched++
|
|
47
|
+
}
|
|
48
|
+
if (header_matches(name, required[1])) candidate_key=i
|
|
49
|
+
}
|
|
50
|
+
if (candidate_target && candidate_key && matched >= required_count) {
|
|
51
|
+
active=1
|
|
52
|
+
target_index=candidate_target
|
|
53
|
+
key_index=candidate_key
|
|
54
|
+
next
|
|
55
|
+
}
|
|
56
|
+
if (active && key_index && target_index && clean(cells[key_index]) == key_value) {
|
|
57
|
+
print clean(cells[target_index])
|
|
58
|
+
found=1
|
|
59
|
+
exit 0
|
|
60
|
+
}
|
|
61
|
+
next
|
|
62
|
+
}
|
|
63
|
+
{ active=0; key_index=0; target_index=0 }
|
|
64
|
+
END { if (!found) exit 1 }
|
|
65
|
+
' "$file"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
markdown_table_column_index_for_row() {
|
|
69
|
+
local file="$1"
|
|
70
|
+
local key_value="$2"
|
|
71
|
+
local target_header="$3"
|
|
72
|
+
shift 3
|
|
73
|
+
local required_headers=("$@")
|
|
74
|
+
|
|
75
|
+
[ -f "$file" ] || return 1
|
|
76
|
+
awk \
|
|
77
|
+
-v key_value="$key_value" \
|
|
78
|
+
-v target_header="$target_header" \
|
|
79
|
+
-v required_headers="$(IFS=,; printf '%s' "${required_headers[*]}")" '
|
|
80
|
+
function clean(value) {
|
|
81
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
|
82
|
+
return value
|
|
83
|
+
}
|
|
84
|
+
function split_row(line, cells, protected) {
|
|
85
|
+
protected=line
|
|
86
|
+
gsub(/\\\|/, "\001", protected)
|
|
87
|
+
return split(protected, cells, "|")
|
|
88
|
+
}
|
|
89
|
+
function header_matches(name, required, options, count, i) {
|
|
90
|
+
count=split(required, options, "/")
|
|
91
|
+
for (i=1; i<=count; i++) {
|
|
92
|
+
if (tolower(name) == tolower(options[i])) return 1
|
|
93
|
+
}
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
BEGIN { required_count=split(required_headers, required, ","); active=0 }
|
|
97
|
+
/^\|/ {
|
|
98
|
+
count=split_row($0, cells)
|
|
99
|
+
candidate_target=0
|
|
100
|
+
candidate_key=0
|
|
101
|
+
matched=0
|
|
102
|
+
for (i=2; i<count; i++) {
|
|
103
|
+
name=clean(cells[i])
|
|
104
|
+
if (tolower(name) == tolower(target_header)) candidate_target=i
|
|
105
|
+
for (r=1; r<=required_count; r++) {
|
|
106
|
+
if (header_matches(name, required[r])) matched++
|
|
107
|
+
}
|
|
108
|
+
if (header_matches(name, required[1])) candidate_key=i
|
|
109
|
+
}
|
|
110
|
+
if (candidate_target && candidate_key && matched >= required_count) {
|
|
111
|
+
active=1
|
|
112
|
+
target_index=candidate_target
|
|
113
|
+
key_index=candidate_key
|
|
114
|
+
next
|
|
115
|
+
}
|
|
116
|
+
if (active && clean(cells[key_index]) == key_value) {
|
|
117
|
+
print target_index
|
|
118
|
+
found=1
|
|
119
|
+
exit 0
|
|
120
|
+
}
|
|
121
|
+
next
|
|
122
|
+
}
|
|
123
|
+
{ active=0 }
|
|
124
|
+
END { if (!found) exit 1 }
|
|
125
|
+
' "$file"
|
|
126
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
# shellcheck source=markdown-table.sh
|
|
6
|
+
source "$SCRIPT_DIR/markdown-table.sh"
|
|
7
|
+
|
|
8
|
+
WORK="$(mktemp -d)"
|
|
9
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
10
|
+
|
|
11
|
+
cat > "$WORK/RTM.md" <<'MARKDOWN'
|
|
12
|
+
| Status | Description |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| Draft | This is not the requirements table |
|
|
15
|
+
|
|
16
|
+
| REQ-ID | Issue | Risk | Evidence | Status |
|
|
17
|
+
| --- | --- | --- | --- | --- |
|
|
18
|
+
| REQ-100 | #100 | HIGH | report.md | TESTED - PENDING SIGN-OFF (note \| retained) |
|
|
19
|
+
MARKDOWN
|
|
20
|
+
|
|
21
|
+
[ "$(markdown_table_cell "$WORK/RTM.md" REQ-100 Status REQ-ID/ID Status)" = \
|
|
22
|
+
'TESTED - PENDING SIGN-OFF (note \| retained)' ]
|
|
23
|
+
[ "$(markdown_table_cell "$WORK/RTM.md" REQ-100 Risk REQ-ID/ID Risk)" = "HIGH" ]
|
|
24
|
+
[ "$(markdown_table_column_index_for_row "$WORK/RTM.md" REQ-100 Status REQ-ID/ID Status)" = "5" ]
|
|
25
|
+
|
|
26
|
+
if markdown_table_cell "$WORK/RTM.md" REQ-404 Status REQ-ID/ID Status >/dev/null; then
|
|
27
|
+
echo "missing row unexpectedly matched" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
if markdown_table_cell "$WORK/RTM.md" Draft Status REQ-ID/ID Status >/dev/null; then
|
|
32
|
+
echo "legend row unexpectedly matched" >&2
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
echo "markdown table tests passed"
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
set -euo pipefail
|
|
14
14
|
|
|
15
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
16
|
+
# shellcheck source=markdown-table.sh
|
|
17
|
+
source "$SCRIPT_DIR/markdown-table.sh"
|
|
18
|
+
|
|
15
19
|
BASE_BRANCH="${1:-origin/main}"
|
|
16
20
|
EXIT_CODE=0
|
|
17
21
|
|
|
@@ -53,6 +57,7 @@ find_first_match() {
|
|
|
53
57
|
local match=""
|
|
54
58
|
match=$(compgen -G "$pattern" | head -1 || true)
|
|
55
59
|
[ -n "$match" ] && printf '%s' "$match"
|
|
60
|
+
return 0
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
require_bundled_fields() {
|
|
@@ -81,7 +86,9 @@ for REQ in $REQUIREMENTS; do
|
|
|
81
86
|
# The `| $REQ ` pattern (leading pipe + trailing space) matches the
|
|
82
87
|
# markdown table row used in RTM, so a substring elsewhere doesn't
|
|
83
88
|
# accidentally satisfy it.
|
|
84
|
-
|
|
89
|
+
RTM_STATUS="$(markdown_table_cell \
|
|
90
|
+
compliance/RTM.md "$REQ" Status REQ-ID/ID Status 2>/dev/null || true)"
|
|
91
|
+
if [ -z "$RTM_STATUS" ]; then
|
|
85
92
|
echo " INFO: $REQ is referenced in commits but has no RTM row — skipping (forward-reference, not a tracked requirement for this release)"
|
|
86
93
|
continue
|
|
87
94
|
fi
|
|
@@ -187,12 +194,12 @@ for REQ in $REQUIREMENTS; do
|
|
|
187
194
|
# by a successor (e.g. REQ-030 superseded by REQ-031). Without this
|
|
188
195
|
# branch every PR with a commit referencing a SUPERSEDED REQ blocks
|
|
189
196
|
# CI (#232).
|
|
190
|
-
if
|
|
191
|
-
if
|
|
197
|
+
if [ -n "$RTM_STATUS" ]; then
|
|
198
|
+
if printf '%s' "$RTM_STATUS" | grep -q "TESTED - PENDING SIGN-OFF"; then
|
|
192
199
|
echo " OK: RTM status is TESTED - PENDING SIGN-OFF"
|
|
193
|
-
elif
|
|
200
|
+
elif printf '%s' "$RTM_STATUS" | grep -q "APPROVED"; then
|
|
194
201
|
echo " OK: RTM status is APPROVED"
|
|
195
|
-
elif
|
|
202
|
+
elif printf '%s' "$RTM_STATUS" | grep -q "SUPERSEDED"; then
|
|
196
203
|
echo " OK: RTM status is SUPERSEDED"
|
|
197
204
|
else
|
|
198
205
|
echo " WARNING: RTM entry exists but status is not TESTED - PENDING SIGN-OFF"
|
|
@@ -371,7 +378,9 @@ for REQ in $REQUIREMENTS; do
|
|
|
371
378
|
fi
|
|
372
379
|
|
|
373
380
|
# Check implementation-plan.md for MEDIUM/HIGH risk (check RTM for risk level)
|
|
374
|
-
|
|
381
|
+
RTM_RISK="$(markdown_table_cell \
|
|
382
|
+
compliance/RTM.md "$REQ" Risk REQ-ID/ID Risk 2>/dev/null || true)"
|
|
383
|
+
if printf '%s' "$RTM_RISK" | grep -qiE 'MEDIUM|HIGH'; then
|
|
375
384
|
if [ ! -f "compliance/evidence/$REQ/implementation-plan.md" ]; then
|
|
376
385
|
echo " ERROR: Implementation plan missing for MEDIUM/HIGH risk: compliance/evidence/$REQ/implementation-plan.md"
|
|
377
386
|
EXIT_CODE=1
|
|
@@ -341,7 +341,7 @@ Reached from Phase 0 for non-tracked change-types. The skill drives this end-to-
|
|
|
341
341
|
- **Post-merge-only CI (older generated workflows — `push: branches: [<integration>]` with no `pull_request:` trigger)** — say so explicitly in the LAST/NEXT sticky: _"no PR-time checks will fire; review + merge is the gate; CI runs post-merge on `$INTEGRATION_BRANCH`."_ Don't poll the PR for checks that won't arrive. The post-merge run (CI Pipeline + Compliance Evidence Upload on the integration branch) is the actual gate; address it via fix-forward if it fails.
|
|
342
342
|
|
|
343
343
|
Either way, never bypass a gate (no `--no-verify`, no `--admin` merge of a red required check); the only difference is **where** you wait for the gate to fire — before merge vs. after merge.
|
|
344
|
-
8. **Guide review -> merge.** A human still reviews the PR. Merge only after all required checks are terminal green on the current PR head SHA; queued, running, stale, cancelled, unexpectedly skipped, or failed checks are not green. There is no portal release approval, UAT four-eyes, Production gate, or standalone close-out on the normal lightweight path.
|
|
344
|
+
8. **Guide review -> merge.** A human still reviews the PR. Merge only after all required checks are terminal green on the current PR head SHA; queued, running, stale, cancelled, unexpectedly skipped, or failed checks are not green. If GitHub leaves a stale projection, use the repository's **Recover Required Checks** workflow: dry-run first, then apply only to rerun a resolvable completed Actions run on the same SHA. When GitHub never created the required suite, push an auditable empty commit to the PR branch and verify the new SHA; never fabricate status or bypass protection. There is no portal release approval, UAT four-eyes, Production gate, or standalone close-out on the normal lightweight path.
|
|
345
345
|
9. **Record the correct release meaning.** A housekeeping push produces at most a bare-date release (`vYYYY.MM.DD`) that is integration/history, not an active approval by default. It waits on `$INTEGRATION_BRANCH` until a later tracked release explicitly absorbs it through the required bundled-changes markdown/JSON manifest and portal lineage. Never infer that an unrelated hotfix or mandatory back-merge belongs to the only pending REQ. A submitted REQ bundle is frozen approval scope; changing it returns that REQ to evidence compilation and UAT resubmission. A doc-only push attaches documents to the existing `REQ-XXX` release.
|
|
346
346
|
10. **Standalone exception.** Only when housekeeping cannot reasonably wait for the next tracked release may the skill prepare a standalone promotion. It must state `Standalone housekeeping promotion` and why in the release PR, add `compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-vYYYY.MM.DD.json` with `schemaVersion: 1`, the exact derived bare-date version, `releaseMode: standalone_housekeeping`, and a specific reason. It must obtain terminal-green CI and PR review. The installed release-scope check validates the declaration before merge; post-deploy uploads it as release-ticket evidence and changes the portal row to standalone housekeeping. Use portal approval only if project policy explicitly opts in. If the installed workflow cannot implement that policy, halt and keep the change on the normal integration path.
|
|
347
347
|
|