@metasession.co/devaudit-cli 0.3.23 → 0.3.25
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/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/ci/ci.yml.template +16 -37
- 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.25",
|
|
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.25",
|
|
37
37
|
"ajv": "^8.20.0",
|
|
38
38
|
"commander": "^12.1.0",
|
|
39
39
|
"consola": "^3.2.3",
|
|
@@ -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" ]
|
|
@@ -161,43 +161,12 @@ jobs:
|
|
|
161
161
|
run: |
|
|
162
162
|
# stderr → /dev/null so warnings can't corrupt the JSON (DevAudit #48)
|
|
163
163
|
npm audit --json > dependency-audit.json 2>/dev/null || true
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
audit = json.load(f)
|
|
171
|
-
path = 'compliance/security/accepted-vulnerabilities.json'
|
|
172
|
-
exceptions = []
|
|
173
|
-
if os.path.exists(path):
|
|
174
|
-
with open(path) as f:
|
|
175
|
-
exceptions = json.load(f).get('exceptions', [])
|
|
176
|
-
def accepted(package, finding):
|
|
177
|
-
advisory_ids = {
|
|
178
|
-
item.get('url', '').rsplit('/', 1)[-1]
|
|
179
|
-
for item in finding.get('via', []) if isinstance(item, dict)
|
|
180
|
-
}
|
|
181
|
-
for item in exceptions:
|
|
182
|
-
required = ('advisoryId', 'package', 'vulnerableRange', 'expiresAt', 'approvedBy', 'reason', 'remediationIssue')
|
|
183
|
-
if not all(item.get(key) for key in required):
|
|
184
|
-
continue
|
|
185
|
-
if item['expiresAt'] < date.today().isoformat():
|
|
186
|
-
continue
|
|
187
|
-
if item['package'] == package and item['vulnerableRange'] == finding.get('range') and item['advisoryId'] in advisory_ids:
|
|
188
|
-
print(f"Accepted temporary risk: {item['advisoryId']} ({package}) until {item['expiresAt']}")
|
|
189
|
-
return True
|
|
190
|
-
return False
|
|
191
|
-
findings = audit.get('vulnerabilities', {})
|
|
192
|
-
unresolved = [name for name, finding in findings.items()
|
|
193
|
-
if finding.get('severity') in ('high', 'critical') and not accepted(name, finding)]
|
|
194
|
-
print(len(unresolved))
|
|
195
|
-
PY
|
|
196
|
-
echo "Unaccepted high/critical: $UNACCEPTED"
|
|
197
|
-
if [ "$UNACCEPTED" != "0" ] && [ "$UNACCEPTED" != "unknown" ]; then
|
|
198
|
-
echo "::error::$UNACCEPTED unaccepted high/critical vulnerability(ies)."
|
|
199
|
-
exit 1
|
|
200
|
-
fi
|
|
164
|
+
# The helper evaluates each high/critical advisory independently
|
|
165
|
+
# against the installed package-lock path and writes an auditable
|
|
166
|
+
# decision record. It is intentionally fail-closed: malformed audit
|
|
167
|
+
# output or a malformed/expired exception fails this gate.
|
|
168
|
+
chmod +x scripts/evaluate-npm-audit.sh 2>/dev/null || true
|
|
169
|
+
bash scripts/evaluate-npm-audit.sh
|
|
201
170
|
|
|
202
171
|
# ── Gate 4: E2E Tests (Playwright) ──
|
|
203
172
|
|
|
@@ -280,6 +249,7 @@ jobs:
|
|
|
280
249
|
path: |
|
|
281
250
|
sast-results.json
|
|
282
251
|
dependency-audit.json
|
|
252
|
+
dependency-risk-evaluation.json
|
|
283
253
|
e2e-results.json
|
|
284
254
|
e2e-auth-results.json
|
|
285
255
|
playwright-report/
|
|
@@ -632,6 +602,15 @@ jobs:
|
|
|
632
602
|
--category security_scan --gate-status "$STATUS_DEPAUDIT" ${FLAGS} "${PRIMARY_LINEAGE_FLAGS[@]}"
|
|
633
603
|
fi
|
|
634
604
|
|
|
605
|
+
# The decision record explains each accepted temporary risk with its
|
|
606
|
+
# exact advisory, installed provenance, expiry, owner, and tracked
|
|
607
|
+
# remediation. It is separate from the raw npm audit artifact.
|
|
608
|
+
if [ -f ci-evidence/dependency-risk-evaluation.json ]; then
|
|
609
|
+
upload dependency-risk-evaluation.json \
|
|
610
|
+
{{PROJECT_SLUG}} _compliance-docs dependency_audit ci-evidence/dependency-risk-evaluation.json \
|
|
611
|
+
--category security_scan --gate-status "$STATUS_DEPAUDIT" ${FLAGS} "${PRIMARY_LINEAGE_FLAGS[@]}"
|
|
612
|
+
fi
|
|
613
|
+
|
|
635
614
|
# Upload E2E test results (e2e_result category)
|
|
636
615
|
if [ -f ci-evidence/e2e-results.json ]; then
|
|
637
616
|
upload e2e-results.json \
|
package/sdlc/package.json
CHANGED