@metasession.co/devaudit-cli 0.3.24 → 0.3.26
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 +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/sdlc/files/_common/scripts/check-release-pr-scope.sh +14 -6
- package/sdlc/files/_common/scripts/check-release-pr-scope.test.sh +44 -0
- package/sdlc/files/_common/scripts/evaluate-npm-audit.sh +247 -0
- package/sdlc/files/_common/scripts/evaluate-npm-audit.test.sh +176 -0
- package/sdlc/files/_common/scripts/generate-security-summary.sh +27 -2
- package/sdlc/files/_common/scripts/generate-security-summary.test.sh +33 -0
- package/sdlc/files/_common/scripts/upload-compliance-documents.sh +484 -0
- package/sdlc/files/ci/ci.yml.template +16 -38
- package/sdlc/files/ci/compliance-evidence.yml.template +8 -469
- 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.26",
|
|
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.26",
|
|
37
37
|
"ajv": "^8.20.0",
|
|
38
38
|
"commander": "^12.1.0",
|
|
39
39
|
"consola": "^3.2.3",
|
|
@@ -27,15 +27,23 @@ import re
|
|
|
27
27
|
title = os.environ.get("PR_TITLE", "")
|
|
28
28
|
body = os.environ.get("PR_BODY", "")
|
|
29
29
|
text = f"{title}\n{body}"
|
|
30
|
+
scope = r"(?:REQ-\d+|v\d+\.\d+\.\d+|v\d{4}\.\d{2}\.\d{2}(?:\.\d+)?)"
|
|
31
|
+
|
|
32
|
+
# An explicit release field is authoritative. Do not let a historical REQ in
|
|
33
|
+
# the surrounding prose override it, and fail closed if its value is invalid.
|
|
34
|
+
explicit_field = re.search(r"^[ \t\-*]*release:\s*(.*)$", text, re.IGNORECASE | re.MULTILINE)
|
|
35
|
+
if explicit_field:
|
|
36
|
+
value = explicit_field.group(1).strip()
|
|
37
|
+
match = re.match(rf"`?({scope})`?(?![A-Za-z0-9_.-])", value, re.IGNORECASE)
|
|
38
|
+
if match:
|
|
39
|
+
print(match.group(1))
|
|
40
|
+
raise SystemExit(0)
|
|
41
|
+
|
|
30
42
|
patterns = [
|
|
31
|
-
|
|
32
|
-
r"\b(REQ-\d+)\b",
|
|
33
|
-
r"\b(v\d+\.\d+\.\d+)\b",
|
|
34
|
-
r"\b(v\d{4}\.\d{2}\.\d{2}(?:\.\d+)?)\b",
|
|
43
|
+
rf"\b({scope})\b",
|
|
35
44
|
]
|
|
36
|
-
flags = re.MULTILINE
|
|
37
45
|
for pattern in patterns:
|
|
38
|
-
match = re.search(pattern, text,
|
|
46
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
39
47
|
if match:
|
|
40
48
|
print(match.group(1))
|
|
41
49
|
break
|
|
@@ -223,5 +223,49 @@ assert_exit "bare-date release without exception marker fails" 1 "$CODE"
|
|
|
223
223
|
assert_grep "exception marker failure is clear" 'explicit standalone housekeeping exception' "$OUT"
|
|
224
224
|
echo ""
|
|
225
225
|
|
|
226
|
+
echo "--- Test 10: Markdown standalone scope wins over historical REQ prose ---"
|
|
227
|
+
make_fixture "$WORK/test10" "chore: standalone housekeeping release"
|
|
228
|
+
cat > "$WORK/test10/compliance/standalone-housekeeping/STANDALONE-HOUSEKEEPING-${BARE_DATE_VERSION}.json" <<EOF
|
|
229
|
+
{
|
|
230
|
+
"schemaVersion": 1,
|
|
231
|
+
"version": "${BARE_DATE_VERSION}",
|
|
232
|
+
"releaseMode": "standalone_housekeeping",
|
|
233
|
+
"reason": "Reviewed operational change cannot wait for the next tracked requirement release."
|
|
234
|
+
}
|
|
235
|
+
EOF
|
|
236
|
+
OUT="$WORK/test10.out"
|
|
237
|
+
if run_check "$WORK/test10" "$OUT" env PR_TITLE="Standalone housekeeping promotion: ${BARE_DATE_VERSION}" PR_BODY=$"## Scope\n- Release: \`${BARE_DATE_VERSION}\`\n- Historical context: REQ-094 close-out recovery.\n"; then
|
|
238
|
+
CODE=0
|
|
239
|
+
else
|
|
240
|
+
CODE=$?
|
|
241
|
+
fi
|
|
242
|
+
assert_exit "Markdown standalone scope wins over historical REQ prose" 0 "$CODE"
|
|
243
|
+
assert_grep "Markdown standalone scope is verified" "Release Scope Integrity verified for ${BARE_DATE_VERSION}" "$OUT"
|
|
244
|
+
echo ""
|
|
245
|
+
|
|
246
|
+
echo "--- Test 11: Markdown tracked scope passes ---"
|
|
247
|
+
make_fixture "$WORK/test11" "[REQ-093] feat: ship release"
|
|
248
|
+
OUT="$WORK/test11.out"
|
|
249
|
+
if run_check "$WORK/test11" "$OUT" env PR_TITLE="release: REQ-093" PR_BODY=$'## Scope\n- Release: `REQ-093`\n'; then
|
|
250
|
+
CODE=0
|
|
251
|
+
else
|
|
252
|
+
CODE=$?
|
|
253
|
+
fi
|
|
254
|
+
assert_exit "Markdown tracked scope passes" 0 "$CODE"
|
|
255
|
+
assert_grep "Markdown tracked scope is verified" 'Release Scope Integrity verified for REQ-093' "$OUT"
|
|
256
|
+
echo ""
|
|
257
|
+
|
|
258
|
+
echo "--- Test 12: malformed explicit scope fails without falling back ---"
|
|
259
|
+
make_fixture "$WORK/test12" "[REQ-093] feat: ship release"
|
|
260
|
+
OUT="$WORK/test12.out"
|
|
261
|
+
if run_check "$WORK/test12" "$OUT" env PR_TITLE="prepare release" PR_BODY=$'## Scope\n- Release: `not-a-release`\n- Historical context: REQ-093\n'; then
|
|
262
|
+
CODE=0
|
|
263
|
+
else
|
|
264
|
+
CODE=$?
|
|
265
|
+
fi
|
|
266
|
+
assert_exit "malformed explicit scope fails" 1 "$CODE"
|
|
267
|
+
assert_grep "malformed explicit scope has clear error" 'do not declare a release scope' "$OUT"
|
|
268
|
+
echo ""
|
|
269
|
+
|
|
226
270
|
echo "=== check-release-pr-scope.test.sh: $PASS passed, $FAIL failed ==="
|
|
227
271
|
[ "$FAIL" -eq 0 ]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# evaluate-npm-audit.sh -- evaluate npm audit high/critical advisories against
|
|
3
|
+
# narrowly governed, repository-owned temporary risk acceptances.
|
|
4
|
+
#
|
|
5
|
+
# The helper intentionally owns both decision logic and machine-readable output.
|
|
6
|
+
# Workflow templates must not parse its human log output or convert evaluation
|
|
7
|
+
# failures into successful "unknown" results.
|
|
8
|
+
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
AUDIT_PATH="dependency-audit.json"
|
|
12
|
+
LOCK_PATH="package-lock.json"
|
|
13
|
+
EXCEPTIONS_PATH="compliance/security/accepted-vulnerabilities.json"
|
|
14
|
+
OUTPUT_PATH="dependency-risk-evaluation.json"
|
|
15
|
+
|
|
16
|
+
usage() {
|
|
17
|
+
echo "Usage: $0 [--audit <path>] [--lock <path>] [--exceptions <path>] [--output <path>]" >&2
|
|
18
|
+
exit 2
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
while [ "$#" -gt 0 ]; do
|
|
22
|
+
case "$1" in
|
|
23
|
+
--audit) AUDIT_PATH="${2:-}"; shift 2 ;;
|
|
24
|
+
--lock) LOCK_PATH="${2:-}"; shift 2 ;;
|
|
25
|
+
--exceptions) EXCEPTIONS_PATH="${2:-}"; shift 2 ;;
|
|
26
|
+
--output) OUTPUT_PATH="${2:-}"; shift 2 ;;
|
|
27
|
+
-h|--help) usage ;;
|
|
28
|
+
*) echo "Error: unknown argument: $1" >&2; usage ;;
|
|
29
|
+
esac
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
for required_path in "$AUDIT_PATH" "$LOCK_PATH"; do
|
|
33
|
+
[ -n "$required_path" ] && [ -f "$required_path" ] || {
|
|
34
|
+
echo "Error: required input is missing: ${required_path:-<empty>}" >&2
|
|
35
|
+
exit 2
|
|
36
|
+
}
|
|
37
|
+
done
|
|
38
|
+
|
|
39
|
+
python3 - "$AUDIT_PATH" "$LOCK_PATH" "$EXCEPTIONS_PATH" "$OUTPUT_PATH" <<'PY'
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
from datetime import date
|
|
44
|
+
|
|
45
|
+
audit_path, lock_path, exceptions_path, output_path = sys.argv[1:]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fail(message):
|
|
49
|
+
print(f"Dependency-risk evaluation error: {message}", file=sys.stderr)
|
|
50
|
+
raise SystemExit(2)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_json(path, label):
|
|
54
|
+
try:
|
|
55
|
+
with open(path, encoding="utf-8") as handle:
|
|
56
|
+
return json.load(handle)
|
|
57
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
58
|
+
fail(f"cannot read {label} {path}: {error}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def package_name_from_path(path):
|
|
62
|
+
marker = "node_modules/"
|
|
63
|
+
if marker not in path:
|
|
64
|
+
fail(f"package-lock node path is not a node_modules path: {path}")
|
|
65
|
+
tail = path.rsplit(marker, 1)[1]
|
|
66
|
+
parts = tail.split("/")
|
|
67
|
+
if not parts or not parts[0]:
|
|
68
|
+
fail(f"cannot derive package name from node path: {path}")
|
|
69
|
+
return "/".join(parts[:2]) if parts[0].startswith("@") else parts[0]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def introducer_for(node_path, packages):
|
|
73
|
+
parent_marker = "/node_modules/"
|
|
74
|
+
if parent_marker not in node_path:
|
|
75
|
+
return "direct"
|
|
76
|
+
parent_path = node_path.rsplit(parent_marker, 1)[0]
|
|
77
|
+
parent = packages.get(parent_path)
|
|
78
|
+
if not isinstance(parent, dict) or not isinstance(parent.get("version"), str):
|
|
79
|
+
fail(f"cannot resolve introducing dependency for {node_path}")
|
|
80
|
+
return f"{package_name_from_path(parent_path)}@{parent['version']}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def advisory_id(advisory):
|
|
84
|
+
url = advisory.get("url")
|
|
85
|
+
if not isinstance(url, str) or "/" not in url:
|
|
86
|
+
fail("high/critical audit advisory is missing a URL")
|
|
87
|
+
value = url.rsplit("/", 1)[-1]
|
|
88
|
+
if not value:
|
|
89
|
+
fail("high/critical audit advisory has an empty advisory ID")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def valid_date(value, field, index):
|
|
94
|
+
if not isinstance(value, str):
|
|
95
|
+
fail(f"exception {index} has no valid {field}")
|
|
96
|
+
try:
|
|
97
|
+
return date.fromisoformat(value)
|
|
98
|
+
except ValueError:
|
|
99
|
+
fail(f"exception {index} has invalid {field}: {value}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
audit = load_json(audit_path, "npm audit JSON")
|
|
103
|
+
lock = load_json(lock_path, "package-lock JSON")
|
|
104
|
+
if not isinstance(audit, dict) or not isinstance(audit.get("vulnerabilities"), dict):
|
|
105
|
+
fail("npm audit JSON must contain a vulnerabilities object")
|
|
106
|
+
if not isinstance(lock, dict) or not isinstance(lock.get("packages"), dict):
|
|
107
|
+
fail("package-lock JSON must contain a packages object")
|
|
108
|
+
|
|
109
|
+
if os.path.exists(exceptions_path):
|
|
110
|
+
exception_document = load_json(exceptions_path, "risk acceptance JSON")
|
|
111
|
+
if not isinstance(exception_document, dict) or exception_document.get("schemaVersion") != 1:
|
|
112
|
+
fail("risk acceptance JSON must be an object with schemaVersion 1")
|
|
113
|
+
exceptions = exception_document.get("exceptions")
|
|
114
|
+
if not isinstance(exceptions, list):
|
|
115
|
+
fail("risk acceptance JSON must contain an exceptions array")
|
|
116
|
+
else:
|
|
117
|
+
exceptions = []
|
|
118
|
+
|
|
119
|
+
required_fields = (
|
|
120
|
+
"advisoryId",
|
|
121
|
+
"package",
|
|
122
|
+
"vulnerableRange",
|
|
123
|
+
"vulnerableVersion",
|
|
124
|
+
"dependencyPath",
|
|
125
|
+
"introducedBy",
|
|
126
|
+
"approvedAt",
|
|
127
|
+
"expiresAt",
|
|
128
|
+
"approvedBy",
|
|
129
|
+
"reason",
|
|
130
|
+
"remediationIssue",
|
|
131
|
+
)
|
|
132
|
+
today = date.today()
|
|
133
|
+
validated_exceptions = []
|
|
134
|
+
seen_exception_keys = set()
|
|
135
|
+
for index, item in enumerate(exceptions, start=1):
|
|
136
|
+
if not isinstance(item, dict):
|
|
137
|
+
fail(f"exception {index} must be an object")
|
|
138
|
+
missing = [field for field in required_fields if not isinstance(item.get(field), str) or not item[field].strip()]
|
|
139
|
+
if missing:
|
|
140
|
+
fail(f"exception {index} is missing required field(s): {', '.join(missing)}")
|
|
141
|
+
approved_at = valid_date(item["approvedAt"], "approvedAt", index)
|
|
142
|
+
expires_at = valid_date(item["expiresAt"], "expiresAt", index)
|
|
143
|
+
if approved_at > today:
|
|
144
|
+
fail(f"exception {index} approvedAt cannot be in the future")
|
|
145
|
+
if expires_at <= today:
|
|
146
|
+
fail(f"exception {index} is expired on {item['expiresAt']}")
|
|
147
|
+
if not item["remediationIssue"].startswith("https://github.com/"):
|
|
148
|
+
fail(f"exception {index} remediationIssue must be a GitHub issue URL")
|
|
149
|
+
key = (item["advisoryId"], item["dependencyPath"])
|
|
150
|
+
if key in seen_exception_keys:
|
|
151
|
+
fail(f"duplicate exception for {item['advisoryId']} at {item['dependencyPath']}")
|
|
152
|
+
seen_exception_keys.add(key)
|
|
153
|
+
validated_exceptions.append(item)
|
|
154
|
+
|
|
155
|
+
packages = lock["packages"]
|
|
156
|
+
accepted = []
|
|
157
|
+
unresolved = []
|
|
158
|
+
seen_advisories = set()
|
|
159
|
+
|
|
160
|
+
for finding_name, finding in audit["vulnerabilities"].items():
|
|
161
|
+
if not isinstance(finding, dict):
|
|
162
|
+
fail(f"audit vulnerability {finding_name} must be an object")
|
|
163
|
+
via = finding.get("via", [])
|
|
164
|
+
if not isinstance(via, list):
|
|
165
|
+
fail(f"audit vulnerability {finding_name} has invalid via data")
|
|
166
|
+
advisory_objects = [
|
|
167
|
+
item for item in via
|
|
168
|
+
if isinstance(item, dict) and item.get("severity") in ("high", "critical")
|
|
169
|
+
]
|
|
170
|
+
if finding.get("severity") in ("high", "critical") and not advisory_objects:
|
|
171
|
+
fail(f"high/critical audit vulnerability {finding_name} has no resolvable advisory object")
|
|
172
|
+
|
|
173
|
+
nodes = finding.get("nodes", [])
|
|
174
|
+
if advisory_objects and (not isinstance(nodes, list) or not nodes):
|
|
175
|
+
fail(f"audit vulnerability {finding_name} has no installed package-lock node")
|
|
176
|
+
|
|
177
|
+
for advisory in advisory_objects:
|
|
178
|
+
package = advisory.get("dependency") or finding_name
|
|
179
|
+
advisory_range = advisory.get("range")
|
|
180
|
+
if not isinstance(package, str) or not package:
|
|
181
|
+
fail(f"advisory in {finding_name} has no package name")
|
|
182
|
+
if not isinstance(advisory_range, str) or not advisory_range:
|
|
183
|
+
fail(f"advisory {advisory_id(advisory)} has no vulnerable range")
|
|
184
|
+
identifier = advisory_id(advisory)
|
|
185
|
+
for node_path in nodes:
|
|
186
|
+
if not isinstance(node_path, str):
|
|
187
|
+
fail(f"advisory {identifier} has invalid package-lock node")
|
|
188
|
+
node = packages.get(node_path)
|
|
189
|
+
if not isinstance(node, dict) or not isinstance(node.get("version"), str):
|
|
190
|
+
fail(f"cannot resolve installed version for {node_path}")
|
|
191
|
+
if package_name_from_path(node_path) != package:
|
|
192
|
+
fail(f"audit package {package} does not match package-lock node {node_path}")
|
|
193
|
+
version = node["version"]
|
|
194
|
+
introduced_by = introducer_for(node_path, packages)
|
|
195
|
+
decision_key = (identifier, node_path)
|
|
196
|
+
if decision_key in seen_advisories:
|
|
197
|
+
continue
|
|
198
|
+
seen_advisories.add(decision_key)
|
|
199
|
+
context = {
|
|
200
|
+
"advisoryId": identifier,
|
|
201
|
+
"severity": advisory["severity"],
|
|
202
|
+
"package": package,
|
|
203
|
+
"vulnerableRange": advisory_range,
|
|
204
|
+
"vulnerableVersion": version,
|
|
205
|
+
"dependencyPath": node_path,
|
|
206
|
+
"introducedBy": introduced_by,
|
|
207
|
+
}
|
|
208
|
+
match = next((item for item in validated_exceptions if all(
|
|
209
|
+
item[field] == context[field]
|
|
210
|
+
for field in ("advisoryId", "package", "vulnerableRange", "vulnerableVersion", "dependencyPath", "introducedBy")
|
|
211
|
+
)), None)
|
|
212
|
+
if match is None:
|
|
213
|
+
unresolved.append(context)
|
|
214
|
+
print(
|
|
215
|
+
f"Unaccepted {context['severity']} risk: {identifier} "
|
|
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
|
+
)
|
|
229
|
+
|
|
230
|
+
result = {
|
|
231
|
+
"schemaVersion": 1,
|
|
232
|
+
"auditFile": audit_path,
|
|
233
|
+
"lockFile": lock_path,
|
|
234
|
+
"accepted": accepted,
|
|
235
|
+
"unresolved": unresolved,
|
|
236
|
+
"summary": {
|
|
237
|
+
"accepted": len(accepted),
|
|
238
|
+
"unresolved": len(unresolved),
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
with open(output_path, "w", encoding="utf-8") as handle:
|
|
242
|
+
json.dump(result, handle, indent=2)
|
|
243
|
+
handle.write("\n")
|
|
244
|
+
|
|
245
|
+
print(f"Dependency risk evaluation: {len(accepted)} accepted, {len(unresolved)} unresolved.")
|
|
246
|
+
raise SystemExit(1 if unresolved else 0)
|
|
247
|
+
PY
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression coverage for advisory-specific, fail-closed npm risk evaluation.
|
|
3
|
+
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
HELPER="$SCRIPT_DIR/evaluate-npm-audit.sh"
|
|
8
|
+
WORK="$(mktemp -d)"
|
|
9
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
10
|
+
|
|
11
|
+
mkdir -p "$WORK/compliance/security"
|
|
12
|
+
|
|
13
|
+
write_audit() {
|
|
14
|
+
cat > "$WORK/dependency-audit.json" <<'JSON'
|
|
15
|
+
{
|
|
16
|
+
"vulnerabilities": {
|
|
17
|
+
"postcss": {
|
|
18
|
+
"name": "postcss",
|
|
19
|
+
"severity": "high",
|
|
20
|
+
"via": [
|
|
21
|
+
{
|
|
22
|
+
"name": "postcss",
|
|
23
|
+
"dependency": "postcss",
|
|
24
|
+
"url": "https://github.com/advisories/GHSA-test-high",
|
|
25
|
+
"severity": "high",
|
|
26
|
+
"range": "<=8.5.11"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"nodes": ["node_modules/next/node_modules/postcss"]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
JSON
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
write_empty_audit() {
|
|
37
|
+
printf '{"vulnerabilities":{}}\n' > "$WORK/dependency-audit.json"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
write_lock() {
|
|
41
|
+
cat > "$WORK/package-lock.json" <<'JSON'
|
|
42
|
+
{
|
|
43
|
+
"lockfileVersion": 3,
|
|
44
|
+
"packages": {
|
|
45
|
+
"": {"name": "fixture"},
|
|
46
|
+
"node_modules/next": {"version": "16.2.11"},
|
|
47
|
+
"node_modules/next/node_modules/postcss": {"version": "8.4.31"}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
JSON
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
write_exception() {
|
|
54
|
+
cat > "$WORK/compliance/security/accepted-vulnerabilities.json" <<'JSON'
|
|
55
|
+
{
|
|
56
|
+
"schemaVersion": 1,
|
|
57
|
+
"exceptions": [
|
|
58
|
+
{
|
|
59
|
+
"advisoryId": "GHSA-test-high",
|
|
60
|
+
"package": "postcss",
|
|
61
|
+
"vulnerableRange": "<=8.5.11",
|
|
62
|
+
"vulnerableVersion": "8.4.31",
|
|
63
|
+
"dependencyPath": "node_modules/next/node_modules/postcss",
|
|
64
|
+
"introducedBy": "next@16.2.11",
|
|
65
|
+
"approvedAt": "2026-07-01",
|
|
66
|
+
"expiresAt": "2026-12-31",
|
|
67
|
+
"approvedBy": "reviewer@example.test",
|
|
68
|
+
"reason": "Fixture acceptance while the introducing dependency awaits an upstream repair.",
|
|
69
|
+
"remediationIssue": "https://github.com/example/repo/issues/1"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
JSON
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
run_helper() {
|
|
77
|
+
bash "$HELPER" \
|
|
78
|
+
--audit "$WORK/dependency-audit.json" \
|
|
79
|
+
--lock "$WORK/package-lock.json" \
|
|
80
|
+
--exceptions "$WORK/compliance/security/accepted-vulnerabilities.json" \
|
|
81
|
+
--output "$WORK/dependency-risk-evaluation.json"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
expect_success() {
|
|
85
|
+
if ! run_helper; then
|
|
86
|
+
echo "Expected evaluation to succeed" >&2
|
|
87
|
+
exit 1
|
|
88
|
+
fi
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect_failure() {
|
|
92
|
+
if run_helper >/dev/null 2>&1; then
|
|
93
|
+
echo "Expected evaluation to fail" >&2
|
|
94
|
+
exit 1
|
|
95
|
+
fi
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
write_audit
|
|
99
|
+
write_lock
|
|
100
|
+
rm -f "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
101
|
+
expect_failure
|
|
102
|
+
|
|
103
|
+
write_empty_audit
|
|
104
|
+
expect_success
|
|
105
|
+
jq -e '.summary.accepted == 0 and .summary.unresolved == 0' "$WORK/dependency-risk-evaluation.json" >/dev/null
|
|
106
|
+
|
|
107
|
+
write_audit
|
|
108
|
+
write_exception
|
|
109
|
+
expect_success
|
|
110
|
+
jq -e '.summary.accepted == 1 and .summary.unresolved == 0 and .accepted[0].acceptance.remediationIssue == "https://github.com/example/repo/issues/1"' \
|
|
111
|
+
"$WORK/dependency-risk-evaluation.json" >/dev/null
|
|
112
|
+
|
|
113
|
+
jq '.exceptions[0].advisoryId = "GHSA-wrong"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
114
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
115
|
+
expect_failure
|
|
116
|
+
|
|
117
|
+
write_exception
|
|
118
|
+
write_audit
|
|
119
|
+
jq '.exceptions[0].vulnerableRange = "<8.5.10"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
120
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
121
|
+
expect_failure
|
|
122
|
+
|
|
123
|
+
write_exception
|
|
124
|
+
write_audit
|
|
125
|
+
jq '.exceptions[0].vulnerableVersion = "8.5.15"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
126
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
127
|
+
expect_failure
|
|
128
|
+
|
|
129
|
+
write_exception
|
|
130
|
+
write_audit
|
|
131
|
+
jq '.exceptions[0].dependencyPath = "node_modules/postcss"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
132
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
133
|
+
expect_failure
|
|
134
|
+
|
|
135
|
+
write_exception
|
|
136
|
+
write_audit
|
|
137
|
+
jq '.exceptions[0].introducedBy = "next@16.2.10"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
138
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
139
|
+
expect_failure
|
|
140
|
+
|
|
141
|
+
write_exception
|
|
142
|
+
write_audit
|
|
143
|
+
jq '.exceptions[0].expiresAt = "2026-01-01"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
144
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
145
|
+
expect_failure
|
|
146
|
+
|
|
147
|
+
write_exception
|
|
148
|
+
write_audit
|
|
149
|
+
jq '.exceptions[0].expiresAt = "not-a-date"' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
150
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
151
|
+
expect_failure
|
|
152
|
+
|
|
153
|
+
write_exception
|
|
154
|
+
write_audit
|
|
155
|
+
jq 'del(.exceptions[0].approvedAt)' "$WORK/compliance/security/accepted-vulnerabilities.json" > "$WORK/changed.json"
|
|
156
|
+
mv "$WORK/changed.json" "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
157
|
+
expect_failure
|
|
158
|
+
|
|
159
|
+
write_exception
|
|
160
|
+
write_audit
|
|
161
|
+
jq '.vulnerabilities.postcss.via += [{"name":"postcss","dependency":"postcss","url":"https://github.com/advisories/GHSA-second-high","severity":"high","range":"<=8.5.11"}]' \
|
|
162
|
+
"$WORK/dependency-audit.json" > "$WORK/changed.json"
|
|
163
|
+
mv "$WORK/changed.json" "$WORK/dependency-audit.json"
|
|
164
|
+
expect_failure
|
|
165
|
+
|
|
166
|
+
write_audit
|
|
167
|
+
write_exception
|
|
168
|
+
printf '{not-json}\n' > "$WORK/dependency-audit.json"
|
|
169
|
+
expect_failure
|
|
170
|
+
|
|
171
|
+
write_audit
|
|
172
|
+
write_exception
|
|
173
|
+
printf '{not-json}\n' > "$WORK/compliance/security/accepted-vulnerabilities.json"
|
|
174
|
+
expect_failure
|
|
175
|
+
|
|
176
|
+
echo "evaluate-npm-audit: PASS"
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
# - `sast-results.json` (Semgrep) — high/critical findings count
|
|
20
20
|
# - `dependency-audit.json` (npm audit / pip-audit) — high/critical
|
|
21
21
|
# vulnerability count
|
|
22
|
+
# - `dependency-risk-evaluation.json` — governed temporary acceptances
|
|
22
23
|
# - `gate-outcomes.json` (DevAudit-Installer v0.1.29) — per-gate
|
|
23
24
|
# pass/fail/skip status
|
|
24
25
|
#
|
|
@@ -91,6 +92,25 @@ dep_audit_summary() {
|
|
|
91
92
|
echo "$TOTAL total vulnerability/ies · $HIGH high · $CRITICAL critical"
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
risk_acceptance_summary() {
|
|
96
|
+
if [ ! -f dependency-risk-evaluation.json ]; then
|
|
97
|
+
echo "No dependency-risk decision artifact was present for this run."
|
|
98
|
+
return
|
|
99
|
+
fi
|
|
100
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
101
|
+
echo "REPLACE — \`jq\` not available; inspect \`dependency-risk-evaluation.json\` manually"
|
|
102
|
+
return
|
|
103
|
+
fi
|
|
104
|
+
jq -r '
|
|
105
|
+
if (.accepted | length) == 0 then
|
|
106
|
+
"No temporary high/critical dependency risks were accepted."
|
|
107
|
+
else
|
|
108
|
+
.accepted[] | "- Accepted \(.advisoryId) — \(.package)@\(.vulnerableVersion), introduced by \(.introducedBy), expires \(.acceptance.expiresAt), owner \(.acceptance.approvedBy), remediation \(.acceptance.remediationIssue)"
|
|
109
|
+
end
|
|
110
|
+
' dependency-risk-evaluation.json 2>/dev/null \
|
|
111
|
+
|| echo "REPLACE — could not parse \`dependency-risk-evaluation.json\`"
|
|
112
|
+
}
|
|
113
|
+
|
|
94
114
|
# Helper: gate-outcomes.json (DevAudit-Installer v0.1.29 onwards).
|
|
95
115
|
gate_outcomes_summary() {
|
|
96
116
|
if [ ! -f gate-outcomes.json ]; then
|
|
@@ -107,6 +127,7 @@ gate_outcomes_summary() {
|
|
|
107
127
|
|
|
108
128
|
SAST_SUMMARY=$(sast_summary)
|
|
109
129
|
DEP_SUMMARY=$(dep_audit_summary)
|
|
130
|
+
RISK_ACCEPTANCE_SUMMARY=$(risk_acceptance_summary)
|
|
110
131
|
GATES=$(gate_outcomes_summary)
|
|
111
132
|
|
|
112
133
|
cat <<EOF
|
|
@@ -133,7 +154,7 @@ generated_by: "generate-security-summary.sh (DevAudit-Installer#116)"
|
|
|
133
154
|
|
|
134
155
|
**Release shape:** $SHAPE
|
|
135
156
|
**Generated:** $TODAY
|
|
136
|
-
**Source data:** \`sast-results.json\` + \`dependency-audit.json\` + \`gate-outcomes.json\` (this CI run)
|
|
157
|
+
**Source data:** \`sast-results.json\` + \`dependency-audit.json\` + \`dependency-risk-evaluation.json\` + \`gate-outcomes.json\` (this CI run)
|
|
137
158
|
|
|
138
159
|
## SAST findings (Semgrep)
|
|
139
160
|
|
|
@@ -145,7 +166,11 @@ $SAST_SUMMARY
|
|
|
145
166
|
|
|
146
167
|
$DEP_SUMMARY
|
|
147
168
|
|
|
148
|
-
> **Policy:** the dependency-audit gate fails
|
|
169
|
+
> **Policy:** the dependency-audit gate fails at \`high\` or \`critical\` severity unless every affected advisory has an exact, unexpired, reviewer-attributed acceptance. The raw audit count may therefore be non-zero only when the governed decision record below identifies the accepted risk.
|
|
170
|
+
|
|
171
|
+
## Governed temporary dependency risks
|
|
172
|
+
|
|
173
|
+
$RISK_ACCEPTANCE_SUMMARY
|
|
149
174
|
|
|
150
175
|
## Gate outcomes (per CI run)
|
|
151
176
|
|
|
@@ -53,5 +53,38 @@ assert_contains "spaced separator in sign-off table" '| --- | --- | --- | --- |'
|
|
|
53
53
|
assert_not_contains_regex "no compact separators remain" '^\|[-:|]+\|$' "$OUTPUT"
|
|
54
54
|
echo
|
|
55
55
|
|
|
56
|
+
DIR2="$TMPDIR_BASE/test2"
|
|
57
|
+
mkdir -p "$DIR2"
|
|
58
|
+
cd "$DIR2"
|
|
59
|
+
printf '{ "results": [] }\n' > sast-results.json
|
|
60
|
+
printf '{ "vulnerabilities": { "postcss": { "severity": "high" } } }\n' > dependency-audit.json
|
|
61
|
+
printf '{ "sast": "PASS", "dependency_audit": "PASS" }\n' > gate-outcomes.json
|
|
62
|
+
cat > dependency-risk-evaluation.json <<'JSON'
|
|
63
|
+
{
|
|
64
|
+
"schemaVersion": 1,
|
|
65
|
+
"accepted": [
|
|
66
|
+
{
|
|
67
|
+
"advisoryId": "GHSA-fixture",
|
|
68
|
+
"package": "postcss",
|
|
69
|
+
"vulnerableVersion": "8.4.31",
|
|
70
|
+
"introducedBy": "next@16.2.11",
|
|
71
|
+
"acceptance": {
|
|
72
|
+
"expiresAt": "2026-12-31",
|
|
73
|
+
"approvedBy": "reviewer@example.test",
|
|
74
|
+
"remediationIssue": "https://github.com/example/repo/issues/1"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"unresolved": []
|
|
79
|
+
}
|
|
80
|
+
JSON
|
|
81
|
+
|
|
82
|
+
echo "Test 2: accepted dependency risk is identified in generated evidence"
|
|
83
|
+
OUTPUT=$(bash "$HELPER" "REQ-322" 2>&1)
|
|
84
|
+
assert_contains "accepted advisory emitted" "Accepted GHSA-fixture" "$OUTPUT"
|
|
85
|
+
assert_contains "accepted risk owner emitted" "owner reviewer@example.test" "$OUTPUT"
|
|
86
|
+
assert_contains "accepted risk remediation emitted" "https://github.com/example/repo/issues/1" "$OUTPUT"
|
|
87
|
+
echo
|
|
88
|
+
|
|
56
89
|
echo "Result: $PASS passed, $FAIL failed"
|
|
57
90
|
[ "$FAIL" = "0" ]
|