@metasession.co/devaudit-cli 0.3.24 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-cli",
3
- "version": "0.3.24",
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.24",
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 the build at \`high\` or \`critical\` severity. If this release shipped, both are zero.
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,44 +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
- # Optional, repository-owned exceptions must be advisory- and
165
- # range-specific, approved, and unexpired. Missing means none.
166
- UNACCEPTED=$(python3 - <<'PY' 2>/dev/null || echo "unknown"
167
- import json, os
168
- from datetime import date
169
- with open('dependency-audit.json') as f:
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
- )
197
- echo "Unaccepted high/critical: $UNACCEPTED"
198
- if [ "$UNACCEPTED" != "0" ] && [ "$UNACCEPTED" != "unknown" ]; then
199
- echo "::error::$UNACCEPTED unaccepted high/critical vulnerability(ies)."
200
- exit 1
201
- 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
202
170
 
203
171
  # ── Gate 4: E2E Tests (Playwright) ──
204
172
 
@@ -281,6 +249,7 @@ jobs:
281
249
  path: |
282
250
  sast-results.json
283
251
  dependency-audit.json
252
+ dependency-risk-evaluation.json
284
253
  e2e-results.json
285
254
  e2e-auth-results.json
286
255
  playwright-report/
@@ -633,6 +602,15 @@ jobs:
633
602
  --category security_scan --gate-status "$STATUS_DEPAUDIT" ${FLAGS} "${PRIMARY_LINEAGE_FLAGS[@]}"
634
603
  fi
635
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
+
636
614
  # Upload E2E test results (e2e_result category)
637
615
  if [ -f ci-evidence/e2e-results.json ]; then
638
616
  upload e2e-results.json \
package/sdlc/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-sdlc",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "description": "DevAudit SDLC CLI Engine — cross-agent terminal-driven SDLC orchestrator.",
5
5
  "bin": {
6
6
  "devaudit-sdlc": "./src/bin/devaudit-sdlc.js"