@blamejs/exceptd-skills 0.18.15 → 0.18.17
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/CHANGELOG.md +38 -0
- package/bin/exceptd.js +36 -1
- package/data/_indexes/_meta.json +3 -3
- package/data/global-frameworks.json +1 -0
- package/lib/auto-discovery.js +10 -3
- package/lib/collectors/ai-api.js +10 -0
- package/lib/collectors/citation-hygiene.js +46 -4
- package/lib/collectors/cred-stores.js +10 -1
- package/lib/collectors/mcp.js +7 -1
- package/lib/collectors/runtime.js +11 -0
- package/lib/collectors/secrets.js +13 -2
- package/lib/cross-ref-api.js +56 -2
- package/lib/framework-gap.js +22 -1
- package/lib/playbook-runner.js +127 -16
- package/lib/prefetch.js +61 -15
- package/lib/refresh-external.js +16 -2
- package/lib/scoring.js +22 -3
- package/lib/source-ghsa.js +12 -4
- package/lib/source-osv.js +11 -4
- package/manifest.json +53 -53
- package/package.json +1 -1
- package/sbom.cdx.json +54 -54
- package/scripts/check-codebase-patterns.js +11 -0
- package/scripts/check-test-subjects.js +16 -1
- package/scripts/check-version-bump.js +35 -5
- package/sources/validators/cve-validator.js +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.17 — 2026-06-24
|
|
4
|
+
|
|
5
|
+
A correctness pass across the upstream-refresh pipeline, the credential and MCP collectors, scoring and framework analysis, attestation hashing, and the release gates.
|
|
6
|
+
|
|
7
|
+
The scheduled data refresh no longer fails on transient upstream throttling. The cache-warm step now separates retryable errors the upstream is asking us to back off on — a rate-limit (HTTP 429), a timeout, a 5xx — from hard data errors like a 404 or a parse failure. Only hard errors count toward the failure budget; transient throttling is surfaced in the run summary and retried on the next run. Previously a burst of NVD rate-limits failed the entire refresh and skipped the automated update PR. A feed that returns nothing at all still fails. The exported `prefetch()` API also honors `EXCEPTD_AIR_GAP=1` now, so a programmatic call from an air-gapped host no longer reaches the network.
|
|
8
|
+
|
|
9
|
+
Cleartext-credential detection covers more of what it claims. An Azure, Google, or Cohere API key found in a shell dotfile now surfaces as a confirmed finding instead of being downgraded to inconclusive. An AWS profile that carries a static access key alongside a `role_arn` — the assume-role bootstrap shape — is flagged as a static-key exposure rather than treated as fully federated. On Windows, the POSIX-permission posture checks (world-writable env files, SSH key modes) report inconclusive rather than a false "clean" for a check that cannot run there.
|
|
10
|
+
|
|
11
|
+
MCP integrity detection catches single-token launch commands. A server started with a whole pip invocation in one argument — `sh -c "pip install pkg==1.2.3"` — is now flagged as pinned-without-integrity; previously only the split-argument form was caught.
|
|
12
|
+
|
|
13
|
+
"Rejected or disputed CVE" citations no longer false-positive. The check fired on any note that mentioned a dispute — including a CVSS *scoring* dispute, a disclosure-coordination dispute, or a different CVE the note merely referenced. It now fires only when the cited CVE's own record is rejected or disputed, and no longer attaches a false attestation that suppressed the downgrade for a valid, often actively-exploited citation.
|
|
14
|
+
|
|
15
|
+
The runtime collector's `collect | run` pipe reaches analysis. The collector now attests the read-only-inventory precondition the runner cannot resolve mechanically, so `collect runtime | run runtime` no longer halts at preflight despite valid evidence.
|
|
16
|
+
|
|
17
|
+
The RWEP-vs-CVSS explanation lists every factor it counts. Suspected and unknown active-exploitation (contributing +10 and +5) and the blast-radius contribution were omitted from the "driving factors" sentence, so a divergence driven by them showed a higher score with an understated — sometimes empty — reason. Both the driving and mitigating sides now enumerate the real contributions and name the structural cause when no single factor dominates.
|
|
18
|
+
|
|
19
|
+
Framework gap analysis resolves frameworks whose catalog label differs from their formal name. The Australian Government ISM — and any framework in the same situation — reported zero control gaps and null metadata because its catalog labels did not match its registered name; a data-driven alias map now resolves both, and a framework that resolves to zero gaps is flagged instead of silently empty.
|
|
20
|
+
|
|
21
|
+
EPSS and NVD scores are matched to the requested CVE by id. The cache-diff and KEV auto-import paths fell back to the first row or record in a payload, which could attribute one CVE's EPSS or CVSS to another when a cache entry held a different CVE's response. They now match by id and skip on a mismatch.
|
|
22
|
+
|
|
23
|
+
Attestation and reattestation no longer report false drift from a relabeled observation. The evidence hash, and the deterministic session id derived from it, re-key evidence by the stable indicator it targets, so identical evidence submitted under a different observation label produces the same hash; a changed evidence value still changes it.
|
|
24
|
+
|
|
25
|
+
The GHSA and OSV regression watch stops flagging editorial fields. Curated-only fields — AI-discovery, PoC availability, active-exploitation — were treated as upstream-sourced and flagged as "dropped" on every re-import; the watch now covers only fields the feed actually populates, and the diff summary counts new entries and field regressions separately rather than labeling both as new.
|
|
26
|
+
|
|
27
|
+
Release gates fail closed on absent input. The version-cadence, test-subject, and code-pattern checks each had a path that passed without checking anything — an unreadable changelog, an empty playbook or module set, an empty scan universe. Each now fails rather than reporting clean when it has nothing to check.
|
|
28
|
+
|
|
29
|
+
## 0.18.16 — 2026-06-22
|
|
30
|
+
|
|
31
|
+
A correctness pass on the evidence-output and attestation surfaces.
|
|
32
|
+
|
|
33
|
+
SARIF finding results always carry a location now. A result with no `locations` is silently dropped by GitHub Code Scanning; previously only playbooks whose look-artifact sources happened to be bare file paths got one, so most playbooks' (prose/glob/command sources) findings were emitted location-less and disappeared from the alerts list. Each finding without a concrete file now carries a rule-scoped logical location, keeping it visible and attributable; a physical file location is still used whenever the agent supplied one.
|
|
34
|
+
|
|
35
|
+
`attest diff` no longer reports identical evidence as changed. The artifact diff was keyed by the operator-chosen observation key rather than the indicator the observation targeted, so two attestations with the same finding under different observation keys (a different collector run, the array vs object evidence shape, reordered observations) showed every artifact as added-and-removed with zero unchanged. The diff is now keyed by the stable indicator id, so identical evidence compares as unchanged.
|
|
36
|
+
|
|
37
|
+
Risk-acceptance exception language now reports its unresolved placeholders. The auditor-ready exception text is filled from operator-supplied values that a standard run does not pre-stage, so it could ship literal `<MISSING:…>` tokens; the exception now carries a `missing_interpolation_vars` list (and surfaces a runtime error) naming exactly which values still need filling, matching the notification-draft behavior.
|
|
38
|
+
|
|
39
|
+
CSAF advisories bind their per-version affected products. The product tree's per-version product entries were defined but never referenced from any vulnerability, so a dashboard correlating affected products to the tree saw only the generic synthetic product; each CVE's version entries are now bound into its `product_status`.
|
|
40
|
+
|
|
3
41
|
## 0.18.15 — 2026-06-22
|
|
4
42
|
|
|
5
43
|
Indicator-gated escalations and playbook chains now fire when their indicators are detected. An escalation or `feeds_into` written as `<indicator-id> == true` (the catalog's standard form) resolved only when that id was also passed as a raw signal; the normal scan path delivers detections separately, so on a real `exceptd collect <playbook>` these escalations and downstream chains stayed silent even when the indicator was found. Fired indicators are now bound into the escalation, chain, and remediation-precondition contexts, so a detection drives the condition that gates on it. An operator can still override a specific indicator, and engine-computed values still take precedence.
|
package/bin/exceptd.js
CHANGED
|
@@ -6554,6 +6554,39 @@ function submissionHasData(submission) {
|
|
|
6554
6554
|
|| nonEmpty(submission.signal_overrides)
|
|
6555
6555
|
|| nonEmpty(submission.observations);
|
|
6556
6556
|
}
|
|
6557
|
+
// attest diff compares artifacts by map key. normalizeSubmission keys
|
|
6558
|
+
// flat-shape artifacts by the operator-chosen OBSERVATION key (obs-kver, x1, an
|
|
6559
|
+
// array index, …), NOT by the stable indicator id the observation targeted — so
|
|
6560
|
+
// two attestations with identical (indicator, value) evidence under different
|
|
6561
|
+
// observation keys diffed as all-added/all-removed (unchanged_count 0), while the
|
|
6562
|
+
// correctly-keyed signal_override_diff on the same comparison showed the true
|
|
6563
|
+
// unchanged result. Re-key the artifact map by the stable indicator id (recovered
|
|
6564
|
+
// from normalizeSubmission's _signal_origins, which maps indicator -> obs-key)
|
|
6565
|
+
// before diffing, falling back to the original key when an observation carries no
|
|
6566
|
+
// indicator binding. Diff-only — does NOT touch normalizeSubmission's map or the
|
|
6567
|
+
// evidence_hash.
|
|
6568
|
+
function rekeyArtifactsByStableId(artifacts, signalOrigins) {
|
|
6569
|
+
if (!artifacts || !signalOrigins || typeof signalOrigins !== "object") return artifacts || {};
|
|
6570
|
+
const obsKeyToIndicator = {};
|
|
6571
|
+
for (const [indicatorId, obsKey] of Object.entries(signalOrigins)) {
|
|
6572
|
+
if (typeof obsKey === "string") obsKeyToIndicator[obsKey] = indicatorId;
|
|
6573
|
+
}
|
|
6574
|
+
const originalKeys = new Set(Object.keys(artifacts));
|
|
6575
|
+
const out = {};
|
|
6576
|
+
for (const [k, v] of Object.entries(artifacts)) {
|
|
6577
|
+
const mapped = obsKeyToIndicator[k];
|
|
6578
|
+
// Re-key to the stable indicator id ONLY when it won't drop an artifact: the
|
|
6579
|
+
// indicator id must not already be a DISTINCT original artifact key, and must
|
|
6580
|
+
// not have been claimed by an earlier re-keyed entry. On any such collision,
|
|
6581
|
+
// keep the original key so no artifact is silently overwritten / lost from
|
|
6582
|
+
// the diff (two observations sharing one indicator, or an artifact key that
|
|
6583
|
+
// already equals an indicator id).
|
|
6584
|
+
const stable = (mapped && mapped !== k && !originalKeys.has(mapped)
|
|
6585
|
+
&& !Object.prototype.hasOwnProperty.call(out, mapped)) ? mapped : k;
|
|
6586
|
+
out[stable] = v;
|
|
6587
|
+
}
|
|
6588
|
+
return out;
|
|
6589
|
+
}
|
|
6557
6590
|
function normalizedArtifacts(submission, runner, playbookId, applyEmptyFallback = true) {
|
|
6558
6591
|
if (!submission || typeof submission !== "object") {
|
|
6559
6592
|
return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
|
|
@@ -6568,7 +6601,9 @@ function normalizedArtifacts(submission, runner, playbookId, applyEmptyFallback
|
|
|
6568
6601
|
const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
|
|
6569
6602
|
if (pb) {
|
|
6570
6603
|
const norm = runner.normalizeSubmission({ observations: submission.observations }, pb);
|
|
6571
|
-
if (norm && norm.artifacts && Object.keys(norm.artifacts).length > 0)
|
|
6604
|
+
if (norm && norm.artifacts && Object.keys(norm.artifacts).length > 0) {
|
|
6605
|
+
return rekeyArtifactsByStableId(norm.artifacts, norm._signal_origins);
|
|
6606
|
+
}
|
|
6572
6607
|
}
|
|
6573
6608
|
} catch { /* fall through */ }
|
|
6574
6609
|
}
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-06-
|
|
3
|
+
"generated_at": "2026-06-25T03:42:18.191Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 64,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "20836959eb7562901f9fb4445fa930f3e6b9890439eb97edefa00de23ff40d93",
|
|
8
8
|
"README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
|
|
9
9
|
"data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
|
|
10
10
|
"data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"data/dlp-controls.json": "d2406c482dddd30e49203879999dc4b3a7fd4d0494d6a61d86b91ee76415df19",
|
|
15
15
|
"data/exploit-availability.json": "ec2656f0d9a893610e27b43eb6035fe9b18e057c9f6dfaac7e7d4959bbcbb795",
|
|
16
16
|
"data/framework-control-gaps.json": "760c2275803c6da3665ce538c5176bde6f041b68cc3d4808b8de961dfcdee6b8",
|
|
17
|
-
"data/global-frameworks.json": "
|
|
17
|
+
"data/global-frameworks.json": "20b18a851b3ab82e57cd159b49476d69fe544f02523398b2015189ab81c7c3f8",
|
|
18
18
|
"data/rfc-references.json": "5db2f7006a1d7f2b8642a3e393213394e92798604576df973d0a0b8550f4b5ac",
|
|
19
19
|
"data/zeroday-lessons.json": "8c69eec9103eeea236ceb1a157d62b35bafcf8a5de86502e25e4587cc5931247",
|
|
20
20
|
"skills/kernel-lpe-triage/skill.md": "0f79c641cef6e5f4a942eb94f43c460562bf83dfb67ae112d146c39c6b320fb0",
|
|
@@ -256,6 +256,7 @@
|
|
|
256
256
|
"frameworks": {
|
|
257
257
|
"ASD_ISM": {
|
|
258
258
|
"full_name": "Australian Signals Directorate Information Security Manual",
|
|
259
|
+
"catalog_aliases": ["au-ism", "AU ISM", "ACSC ISM", "Australian Government Information Security Manual"],
|
|
259
260
|
"authority": "Australian Signals Directorate (ASD)",
|
|
260
261
|
"source": "https://www.cyber.gov.au/resources-business-and-government/essential-cyber-security/ism",
|
|
261
262
|
"effective_date": "Monthly updates",
|
package/lib/auto-discovery.js
CHANGED
|
@@ -185,8 +185,15 @@ function readCachedJson(cacheDir, source, id) {
|
|
|
185
185
|
return parsed;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
function extractNvdMetrics(payload) {
|
|
189
|
-
|
|
188
|
+
function extractNvdMetrics(payload, id) {
|
|
189
|
+
// Resolve the NVD vuln by id, not by position. vulnerabilities[0] blindly
|
|
190
|
+
// took the first record, so a cache entry keyed under `id` that held another
|
|
191
|
+
// CVE's response would attribute that CVE's CVSS/CWE/description to this id.
|
|
192
|
+
// When no id is supplied (legacy callers), fall back to the first record.
|
|
193
|
+
const cves = (payload?.vulnerabilities || []).map((v) => v?.cve).filter(Boolean);
|
|
194
|
+
const vuln = id
|
|
195
|
+
? cves.find((c) => c.id && String(c.id).toUpperCase() === String(id).toUpperCase())
|
|
196
|
+
: cves[0];
|
|
190
197
|
if (!vuln) return null;
|
|
191
198
|
// Prefer the newest CVSS version (Primary within it) and normalize a bare
|
|
192
199
|
// v2 vector to its canonical prefix so an auto-imported draft never carries
|
|
@@ -238,7 +245,7 @@ function extractEpss(payload, id) {
|
|
|
238
245
|
*/
|
|
239
246
|
function buildKevDraftEntry(kevEntry, nvdPayload, epssPayload) {
|
|
240
247
|
const id = String(kevEntry.cveID);
|
|
241
|
-
const nvd = nvdPayload ? extractNvdMetrics(nvdPayload) : null;
|
|
248
|
+
const nvd = nvdPayload ? extractNvdMetrics(nvdPayload, id) : null;
|
|
242
249
|
const epss = epssPayload ? extractEpss(epssPayload, id) : null;
|
|
243
250
|
|
|
244
251
|
const knownRansomware =
|
package/lib/collectors/ai-api.js
CHANGED
|
@@ -63,6 +63,16 @@ const AI_KEY_VALUE_RE = {
|
|
|
63
63
|
openai: /OPENAI_API_KEY\s*[= ]\s*['"]?(sk-[A-Za-z0-9_-]+)/,
|
|
64
64
|
anthropic: /ANTHROPIC_API_KEY\s*[= ]\s*['"]?(sk-ant-[A-Za-z0-9_-]+)/,
|
|
65
65
|
huggingface: /(?:HUGGINGFACE_TOKEN|HF_TOKEN)\s*[= ]\s*['"]?(hf_[A-Za-z0-9]+)/,
|
|
66
|
+
// Azure/Google/Cohere keys carry no vendor prefix, so the captured value IS
|
|
67
|
+
// the entropy body. Without these, cleartextFpIndices found no value for an
|
|
68
|
+
// azure/google/cohere-only dotfile, returned an empty attestation, and the
|
|
69
|
+
// runner downgraded a real cleartext-key hit to inconclusive — the indicator
|
|
70
|
+
// fired then vanished for half the supported vendors. The 30-char entropy
|
|
71
|
+
// floor (the `: 30` else-branch below) and PLACEHOLDER_RE both apply, since
|
|
72
|
+
// the prefix-strip leaves these values unchanged.
|
|
73
|
+
azure: /AZURE_OPENAI(?:_API)?_KEY\s*[= ]\s*['"]?([A-Za-z0-9]{20,})/,
|
|
74
|
+
google: /(?:GOOGLE_API_KEY|GOOGLE_GENAI_API_KEY|GEMINI_API_KEY)\s*[= ]\s*['"]?([A-Za-z0-9_-]{20,})/,
|
|
75
|
+
cohere: /COHERE_API_KEY\s*[= ]\s*['"]?([A-Za-z0-9-]{30,})/,
|
|
66
76
|
};
|
|
67
77
|
const PLACEHOLDER_RE = /placeholder|example|redacted|dummy|x{4,}|0{6,}|test-/i;
|
|
68
78
|
|
|
@@ -110,8 +110,50 @@ const CVE_CANONICAL_RE = /^CVE-\d{4}-\d{4,}$/;
|
|
|
110
110
|
// RFC citation: `RFC 9404`, `RFC9404`, `RFC-9404`. Capture the number.
|
|
111
111
|
const RFC_CITATION_RE = /RFC[\s-]?(\d{1,5})\b/gi;
|
|
112
112
|
|
|
113
|
-
//
|
|
114
|
-
|
|
113
|
+
// A catalog note records a rejected/disputed RECORD for THIS CVE only when a
|
|
114
|
+
// reject/dispute/withdraw word refers to the citation's own record — not to a
|
|
115
|
+
// CVSS *scoring* disagreement, a disclosure-coordination dispute, or a
|
|
116
|
+
// DIFFERENT CVE the note merely mentions. A bare word-anywhere scan matched all
|
|
117
|
+
// three, producing a false "citation to a rejected record" hit AND a false
|
|
118
|
+
// __fp_checks[1] attestation (telling the runner not to downgrade), so a valid,
|
|
119
|
+
// often actively-exploited CVE surfaced as a confirmed rejected/disputed
|
|
120
|
+
// citation. `selfId` is the citation's own CVE id.
|
|
121
|
+
function recordRejectedOrDisputed(note, selfId) {
|
|
122
|
+
if (!note) return false;
|
|
123
|
+
const self = String(selfId || "").toUpperCase();
|
|
124
|
+
const re = /\b(reject(?:ed|s|ion)?|disputed?|withdrawn)\b/gi;
|
|
125
|
+
// Qualifier nouns that make a "dispute" a disagreement about something OTHER
|
|
126
|
+
// than the record's validity (the score, the severity, the disclosure
|
|
127
|
+
// process, …). 'rejected'/'withdrawn' are record-level words and bypass this.
|
|
128
|
+
const QUALIFIER = /\b(cvss|scoring|score|severity|coordination|disclosure|methodolog\w*|attribution|naming|assignment|priorit\w*)\b/i;
|
|
129
|
+
// A "duplicate of / superseded by / replaced by / merged into / in favour of"
|
|
130
|
+
// construction names the REPLACEMENT cve — THIS record is still the rejected
|
|
131
|
+
// one, so a different cve appearing as that replacement must NOT suppress the
|
|
132
|
+
// flag (e.g. "this record was rejected as a duplicate of CVE-Y").
|
|
133
|
+
const REPLACEMENT_OF = /\b(?:duplicate|dup)\b[\s\w-]*\bof\b|\b(?:supersed\w+|replaced|merged)\b[\s\w-]*\b(?:by|into)\b|\bin\s+favou?r\s+of\b/i;
|
|
134
|
+
const otherCve = (s) => (s.match(/CVE-\d{4}-\d{4,}/gi) || []).some((c) => c.toUpperCase() !== self);
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = re.exec(note)) !== null) {
|
|
137
|
+
const word = m[1].toLowerCase();
|
|
138
|
+
const before = note.slice(Math.max(0, m.index - 60), m.index);
|
|
139
|
+
const after = note.slice(re.lastIndex, re.lastIndex + 60);
|
|
140
|
+
// (a) A different cve BEFORE the word is the subject ("CVE-Y was rejected")
|
|
141
|
+
// — the status is about that record, not this citation.
|
|
142
|
+
if (otherCve(before)) continue;
|
|
143
|
+
// (b) A different cve AFTER the word suppresses too, UNLESS it is the
|
|
144
|
+
// replacement target of a duplicate-of/superseded-by construction, in
|
|
145
|
+
// which case THIS record is the rejected one — keep it flagged.
|
|
146
|
+
if (otherCve(after) && !REPLACEMENT_OF.test(after)) continue;
|
|
147
|
+
// (c) A 'dispute(d)' qualified by a non-record noun is a disagreement about
|
|
148
|
+
// that noun, not a record rejection.
|
|
149
|
+
if (word.startsWith("disput")) {
|
|
150
|
+
const lastTokens = before.trim().split(/[\s-]+/).slice(-3).join(" ");
|
|
151
|
+
if (QUALIFIER.test(lastTokens)) continue;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
115
157
|
|
|
116
158
|
// Draft-language proximity for the (unflipped) draft-as-RFC heuristic.
|
|
117
159
|
const DRAFT_LANGUAGE_RE = /\b(draft-[a-z0-9-]+|internet[- ]draft|work[- ]in[- ]progress|i-d\b)\b/i;
|
|
@@ -356,7 +398,7 @@ function collect({ cwd = process.cwd() } = {}) {
|
|
|
356
398
|
// Well-formed. Cross-reference the catalog.
|
|
357
399
|
if (cveKeys.has(full)) {
|
|
358
400
|
const note = cveNotes.get(full) || "";
|
|
359
|
-
if (
|
|
401
|
+
if (recordRejectedOrDisputed(note, full) && !illustrative) {
|
|
360
402
|
hits["rejected-or-disputed-cve"].push({ file: f.rel, citation: full, line: cveLine });
|
|
361
403
|
}
|
|
362
404
|
} else if (catalogsLoaded && !illustrative) {
|
|
@@ -603,4 +645,4 @@ async function applyResolution(submission, opts = {}) {
|
|
|
603
645
|
return out;
|
|
604
646
|
}
|
|
605
647
|
|
|
606
|
-
module.exports = { playbook_id: COLLECTOR_ID, collect, applyResolution };
|
|
648
|
+
module.exports = { playbook_id: COLLECTOR_ID, collect, applyResolution, recordRejectedOrDisputed };
|
|
@@ -84,7 +84,16 @@ function parseAwsCredentials(content) {
|
|
|
84
84
|
const staticKeys = {};
|
|
85
85
|
for (const [name, kv] of Object.entries(profiles)) {
|
|
86
86
|
const hasKey = !!kv["aws_access_key_id"];
|
|
87
|
-
|
|
87
|
+
// role_arn is NOT a federation marker for this predicate. A profile that
|
|
88
|
+
// carries a static aws_access_key_id (AKIA*) alongside a role_arn is the
|
|
89
|
+
// canonical source_profile/assume-role setup where the static key IS the
|
|
90
|
+
// long-lived credential that bootstraps the assumed role — a real static-key
|
|
91
|
+
// exposure. The playbook's aws-static-key-present predicate lists only
|
|
92
|
+
// sso_session / credential_process as federation; treating role_arn as
|
|
93
|
+
// federation here suppressed a genuinely-present IAM user key. A pure
|
|
94
|
+
// assume-role profile (role_arn, no own access key) is still excluded by the
|
|
95
|
+
// hasKey gate below.
|
|
96
|
+
const hasFederation = !!(kv["sso_session"] || kv["credential_process"]);
|
|
88
97
|
if (hasKey && !hasFederation) {
|
|
89
98
|
staticProfiles.push(name);
|
|
90
99
|
staticKeys[name] = kv["aws_access_key_id"];
|
package/lib/collectors/mcp.js
CHANGED
|
@@ -120,7 +120,13 @@ function isPinnedNoIntegrity(server) {
|
|
|
120
120
|
if (typeof tok !== "string") continue;
|
|
121
121
|
// npm package@version (excluding scope name@version)
|
|
122
122
|
if (/@[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@\d+\.\d+\.\d+\b/.test(tok)) return true;
|
|
123
|
-
|
|
123
|
+
// A `==X.Y.Z` pin (pip/uvx/poetry) with no integrity is the python analog
|
|
124
|
+
// of the npm pin above. A single-token launch command like
|
|
125
|
+
// `sh -c "pip install some-mcp==1.2.3"` carries the whole pip invocation in
|
|
126
|
+
// one token, so testing the token for `pip install` and `continue`-ing here
|
|
127
|
+
// skipped the `==version` check below for exactly the shape the comment
|
|
128
|
+
// says operators care about. A bare `pip install requests` (no `==`) can't
|
|
129
|
+
// match the pin regex, so checking every token is safe.
|
|
124
130
|
if (/==\d+\.\d+\.\d+/.test(tok)) return true;
|
|
125
131
|
}
|
|
126
132
|
return false;
|
|
@@ -341,6 +341,17 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
341
341
|
return {
|
|
342
342
|
precondition_checks: {
|
|
343
343
|
"linux-platform": true,
|
|
344
|
+
// The collector performs a read-only inventory directly via fs (reads
|
|
345
|
+
// /etc/sudoers, /etc/passwd, /proc/<pid>/status) and execs no commands,
|
|
346
|
+
// so the `exec-allowed` precondition ('permitted to execute read-only
|
|
347
|
+
// inventory commands') is satisfied by construction once the collection
|
|
348
|
+
// reaches at least one inventory source. The runner cannot mechanically
|
|
349
|
+
// resolve `agent_has_command_exec == true`, so without this attestation
|
|
350
|
+
// the on_fail=halt preflight blocked the canonical `collect runtime | run
|
|
351
|
+
// runtime` pipe even with valid evidence — the sibling fs-only cred-stores
|
|
352
|
+
// collector attests its own halt precondition the same way. Gated on
|
|
353
|
+
// readability so a fully-masked /proc+/etc scope reports it false.
|
|
354
|
+
"exec-allowed": Boolean(sudoersReadable || passwdContent != null || procWalkable || anyTpReadable),
|
|
344
355
|
},
|
|
345
356
|
artifacts,
|
|
346
357
|
signal_overrides,
|
|
@@ -488,7 +488,14 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
488
488
|
// any .env / .env.* / .envrc with mode 0666 or 0664 (group/world writable)
|
|
489
489
|
// i.e. group-write OR world-write bit set (mode & 0o022).
|
|
490
490
|
const envFilePostures = process.platform === "win32" ? [] : envFiles.map(f => ({ file: f.rel, ...statPosture(f.full) }));
|
|
491
|
-
|
|
491
|
+
// POSIX mode bits are unreadable on win32, so this posture cannot be checked
|
|
492
|
+
// there. OMIT the signal on Windows (mirroring cred-stores' credentials-file-
|
|
493
|
+
// bad-perms) so the runner returns `inconclusive` rather than a deterministic
|
|
494
|
+
// false `miss` for a check that physically cannot run. (The posture array is
|
|
495
|
+
// still declared — empty on win32 — for the SARIF per-indicator locations.)
|
|
496
|
+
if (process.platform !== "win32") {
|
|
497
|
+
signal_overrides["world-writable-env-file"] = envFilePostures.some(p => p.error == null && (p.mode & 0o022) !== 0) ? "hit" : "miss";
|
|
498
|
+
}
|
|
492
499
|
|
|
493
500
|
// ssh-key-bad-perms predicate (per playbook):
|
|
494
501
|
// restricted to ssh-private-keys artifact + ~/.ssh/id_* paths
|
|
@@ -499,7 +506,11 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
499
506
|
// (prodSshPrivateKeys) — matching ssh-private-key-block — so a fixture
|
|
500
507
|
// key checked in under a test/ path doesn't raise a bad-perms posture.
|
|
501
508
|
const sshKeyPostures = process.platform === "win32" ? [] : prodSshPrivateKeys.map(f => ({ file: f.rel, ...statPosture(f.full) }));
|
|
502
|
-
|
|
509
|
+
// Same as world-writable-env-file: omit on win32 (mode bits unreadable) so the
|
|
510
|
+
// runner returns inconclusive instead of a forced false `miss`.
|
|
511
|
+
if (process.platform !== "win32") {
|
|
512
|
+
signal_overrides["ssh-key-bad-perms"] = sshKeyPostures.some(p => p.error == null && p.mode !== 0o600) ? "hit" : "miss";
|
|
513
|
+
}
|
|
503
514
|
|
|
504
515
|
// Per-indicator file locations for every indicator flipped to "hit", so
|
|
505
516
|
// a SARIF result points at the file carrying the secret / bad posture.
|
package/lib/cross-ref-api.js
CHANGED
|
@@ -303,13 +303,67 @@ function bySkill(skillName) {
|
|
|
303
303
|
return { skill: skillName, summary_card: card, cve_refs: cveRefs, ttp_refs: ttpRefs };
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// global-frameworks.json is keyed by REGION (EU/UK/AU/...), each with a nested
|
|
307
|
+
// `frameworks: { SHORTKEY: {full_name, catalog_aliases?, ...} }` map. A flat
|
|
308
|
+
// `global[frameworkId]` lookup therefore ALWAYS returned null — frameworkId is a
|
|
309
|
+
// short key or a catalog display name, never a region — so framework_meta was
|
|
310
|
+
// universally null. Walk the nested structure and match the requested id against
|
|
311
|
+
// the short key, the full_name, or any catalog_alias (normalized), returning the
|
|
312
|
+
// matched framework object annotated with its region + jurisdiction.
|
|
313
|
+
function resolveFrameworkMeta(global, frameworkId) {
|
|
314
|
+
if (!global || frameworkId == null) return null;
|
|
315
|
+
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
|
|
316
|
+
const want = norm(frameworkId);
|
|
317
|
+
if (!want) return null;
|
|
318
|
+
for (const [region, rv] of Object.entries(global)) {
|
|
319
|
+
if (region === '_meta' || !rv || typeof rv !== 'object') continue;
|
|
320
|
+
const fws = rv.frameworks || {};
|
|
321
|
+
for (const [shortKey, fv] of Object.entries(fws)) {
|
|
322
|
+
if (!fv || typeof fv !== 'object') continue;
|
|
323
|
+
const aliases = Array.isArray(fv.catalog_aliases) ? fv.catalog_aliases.map(norm) : [];
|
|
324
|
+
if (shortKey === frameworkId ||
|
|
325
|
+
norm(shortKey) === want ||
|
|
326
|
+
(fv.full_name && norm(fv.full_name) === want) ||
|
|
327
|
+
aliases.some((a) => a && (a === want || a.includes(want) || want.includes(a)))) {
|
|
328
|
+
return { ...fv, _framework_key: shortKey, _region: region, _jurisdiction: rv.jurisdiction || null };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
306
335
|
function byFramework(frameworkId) {
|
|
307
336
|
const gaps = loadCatalog('framework-control-gaps.json');
|
|
308
337
|
const global = loadCatalog('global-frameworks.json');
|
|
338
|
+
const fwMeta = resolveFrameworkMeta(global, frameworkId);
|
|
339
|
+
// Match gap rows by the framework's full LABEL SET, not just the literal id.
|
|
340
|
+
// The catalog stores a framework's gaps under labels (au-ism / AU ISM / ACSC
|
|
341
|
+
// ISM …) that diverge from its short key and full_name, so an exact
|
|
342
|
+
// `g.framework === frameworkId` match returned a gap set inconsistent with the
|
|
343
|
+
// (now alias-resolved) framework_meta — non-null metadata but a partial,
|
|
344
|
+
// sometimes empty, gap list. Resolve the same alias set used for the metadata
|
|
345
|
+
// and match any gap label (string or array element) against it.
|
|
346
|
+
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s_-]/g, '');
|
|
347
|
+
const labels = new Set([norm(frameworkId)]);
|
|
348
|
+
if (fwMeta) {
|
|
349
|
+
if (fwMeta._framework_key) labels.add(norm(fwMeta._framework_key));
|
|
350
|
+
if (fwMeta.full_name) labels.add(norm(fwMeta.full_name));
|
|
351
|
+
for (const a of (Array.isArray(fwMeta.catalog_aliases) ? fwMeta.catalog_aliases : [])) labels.add(norm(a));
|
|
352
|
+
}
|
|
353
|
+
labels.delete('');
|
|
354
|
+
const labelMatch = (fw) => {
|
|
355
|
+
const n = norm(fw);
|
|
356
|
+
if (!n) return false;
|
|
357
|
+
for (const l of labels) if (n === l || n.includes(l) || l.includes(n)) return true;
|
|
358
|
+
return false;
|
|
359
|
+
};
|
|
309
360
|
const matching = entries(gaps)
|
|
310
|
-
.filter(([, g]) =>
|
|
361
|
+
.filter(([, g]) => {
|
|
362
|
+
if (g.framework === 'ALL') return true;
|
|
363
|
+
const fwList = Array.isArray(g.framework) ? g.framework : [g.framework];
|
|
364
|
+
return fwList.some(labelMatch);
|
|
365
|
+
})
|
|
311
366
|
.map(([id, g]) => ({ id, ...g }));
|
|
312
|
-
const fwMeta = global[frameworkId] || null;
|
|
313
367
|
return { framework: frameworkId, framework_meta: fwMeta, gaps: matching, gap_count: matching.length };
|
|
314
368
|
}
|
|
315
369
|
|
package/lib/framework-gap.js
CHANGED
|
@@ -108,6 +108,17 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
|
|
|
108
108
|
const normalize = (s) => String(s).toLowerCase().replace(/[\s_-]/g, '');
|
|
109
109
|
const idNorm = normalize(frameworkId);
|
|
110
110
|
const nameNorm = frameworkData?.full_name ? normalize(frameworkData.full_name) : null;
|
|
111
|
+
// Data-driven aliases close the naming divergence between a framework's
|
|
112
|
+
// global-frameworks full_name and the (often different) labels the control-gap
|
|
113
|
+
// catalog uses for it — e.g. ASD_ISM's full_name is "Australian Signals
|
|
114
|
+
// Directorate Information Security Manual" but the catalog labels its 5 gaps
|
|
115
|
+
// "au-ism" / "ACSC ISM" / "Australian Government Information Security Manual",
|
|
116
|
+
// so neither nameNorm nor idNorm matched and lagScore reported 0 gaps. Aliases
|
|
117
|
+
// live in data/global-frameworks.json (catalog_aliases) so this generalizes to
|
|
118
|
+
// any future framework whose catalog label diverges from its name.
|
|
119
|
+
const aliasNorms = Array.isArray(frameworkData?.catalog_aliases)
|
|
120
|
+
? frameworkData.catalog_aliases.map((a) => normalize(a)).filter(Boolean)
|
|
121
|
+
: [];
|
|
111
122
|
const gaps = Object.entries(controlGaps).filter(([key, g]) => {
|
|
112
123
|
if (key.startsWith('_')) return false;
|
|
113
124
|
if (g.status !== 'open' || !g.framework) return false;
|
|
@@ -116,10 +127,17 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
|
|
|
116
127
|
const fwNorm = normalize(fw);
|
|
117
128
|
if (nameNorm && fwNorm.includes(nameNorm)) return true; // display-name match
|
|
118
129
|
if (fwNorm.includes(idNorm)) return true; // short-key substring
|
|
130
|
+
if (aliasNorms.some((a) => a && (fwNorm.includes(a) || a.includes(fwNorm)))) return true; // catalog-label alias
|
|
119
131
|
}
|
|
120
132
|
if (normalize(key).startsWith(idNorm)) return true; // gap-key prefix
|
|
121
133
|
return false;
|
|
122
134
|
});
|
|
135
|
+
// Observable backstop: a framework that EXISTS in global-frameworks resolving
|
|
136
|
+
// to zero catalog gaps is almost always a fresh naming divergence (a new
|
|
137
|
+
// catalog label not yet in catalog_aliases), not a genuinely gap-free
|
|
138
|
+
// framework. Surface it as a structured flag rather than a silent 0 so a
|
|
139
|
+
// regression is greppable in the breakdown instead of invisible.
|
|
140
|
+
const resolvedButZeroGaps = Boolean(frameworkData && gaps.length === 0);
|
|
123
141
|
|
|
124
142
|
const universalGaps = Object.values(controlGaps).filter(g =>
|
|
125
143
|
g.framework === 'ALL' && g.status === 'open'
|
|
@@ -149,7 +167,10 @@ function lagScore(frameworkId, controlGaps, globalFrameworks) {
|
|
|
149
167
|
ai_coverage: { coverage: frameworkData?.ai_coverage ?? 'unknown', score: aiCoverageScore },
|
|
150
168
|
pqc_coverage: { coverage: frameworkData?.pqc_coverage ?? 'unknown', score: pqcScore },
|
|
151
169
|
universal_gaps: { count: universalGaps.length, score: universalGapScore },
|
|
152
|
-
framework_specific_gaps: gaps.length
|
|
170
|
+
framework_specific_gaps: gaps.length,
|
|
171
|
+
// True only when the framework resolves in global-frameworks yet matched
|
|
172
|
+
// zero catalog gaps — a likely naming divergence worth investigating.
|
|
173
|
+
framework_resolved_but_zero_gaps: resolvedButZeroGaps
|
|
153
174
|
}
|
|
154
175
|
};
|
|
155
176
|
}
|