@blamejs/exceptd-skills 0.19.0 → 0.19.2
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/AGENTS.md +5 -5
- package/ARCHITECTURE.md +3 -3
- package/CHANGELOG.md +21 -1
- package/CONTEXT.md +6 -6
- package/README.md +6 -6
- package/data/_indexes/_meta.json +46 -46
- package/data/_indexes/activity-feed.json +17 -17
- package/data/_indexes/catalog-summaries.json +10 -10
- package/data/_indexes/chains.json +2890 -2798
- package/data/_indexes/frequency.json +12 -8
- package/data/_indexes/recipes.json +1 -1
- package/data/_indexes/section-offsets.json +165 -165
- package/data/_indexes/summary-cards.json +4 -4
- package/data/_indexes/token-budget.json +68 -68
- package/data/_indexes/xref.json +2 -2
- package/data/atlas-ttps.json +140 -79
- package/data/attack-techniques.json +22 -209
- package/data/cve-catalog.json +440 -332
- package/data/d3fend-catalog.json +5 -5
- package/data/dlp-controls.json +1 -1
- package/data/framework-control-gaps.json +8 -8
- package/data/playbooks/audit-log-integrity.json +2 -2
- package/data/playbooks/hardening.json +1 -1
- package/data/zeroday-lessons.json +2 -2
- package/manifest-snapshot.json +8 -8
- package/manifest-snapshot.sha256 +1 -1
- package/manifest.json +95 -95
- package/package.json +2 -2
- package/sbom.cdx.json +166 -121
- package/scripts/builders/catalog-summaries.js +1 -1
- package/scripts/builders/recipes.js +1 -1
- package/scripts/check-epss-consistency.js +189 -0
- package/scripts/check-ttp-references.js +152 -0
- package/scripts/check-ttp-upstream.js +202 -0
- package/scripts/check-version-tags.js +50 -6
- package/scripts/predeploy.js +26 -0
- package/skills/age-gates-child-safety/skill.md +2 -2
- package/skills/ai-attack-surface/skill.md +3 -3
- package/skills/ai-c2-detection/skill.md +4 -4
- package/skills/api-security/skill.md +1 -1
- package/skills/attack-surface-pentest/skill.md +3 -3
- package/skills/audit-log-integrity/skill.md +3 -3
- package/skills/cloud-iam-incident/skill.md +1 -1
- package/skills/cloud-security/skill.md +2 -2
- package/skills/compliance-theater/skill.md +2 -2
- package/skills/container-runtime-security/skill.md +2 -2
- package/skills/coordinated-vuln-disclosure/skill.md +1 -1
- package/skills/defensive-countermeasure-mapping/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +4 -4
- package/skills/exploit-scoring/skill.md +1 -1
- package/skills/framework-gap-analysis/skill.md +3 -3
- package/skills/fuzz-testing-strategy/skill.md +1 -1
- package/skills/incident-response-playbook/skill.md +7 -7
- package/skills/mcp-agent-trust/skill.md +1 -1
- package/skills/mlops-security/skill.md +3 -3
- package/skills/ot-ics-security/skill.md +5 -5
- package/skills/policy-exception-gen/skill.md +2 -2
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +4 -4
- package/skills/ransomware-response/skill.md +2 -2
- package/skills/sector-energy/skill.md +9 -9
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +1 -1
- package/skills/skill-update-loop/skill.md +5 -5
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +7 -7
- package/skills/threat-modeling-methodology/skill.md +1 -1
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +2 -2
|
@@ -18,7 +18,7 @@ const path = require("path");
|
|
|
18
18
|
const CATALOG_PURPOSES = {
|
|
19
19
|
"cve-catalog.json": "Per-CVE record (CVSS, EPSS, CISA KEV, RWEP, AI-discovery, vendor advisories, framework gaps, ATLAS/ATT&CK mappings). Cross-validated against NVD + CISA KEV + FIRST EPSS via validate-cves.",
|
|
20
20
|
"cwe-catalog.json": "MITRE CWE entries used by the project (subset with skill citations), with severity hint and category. Pinned to a CWE catalog version.",
|
|
21
|
-
"atlas-ttps.json": "MITRE ATLAS TTPs (AML.T0xxx) cited by skills, with tactic, name, description. Pinned to ATLAS v2026.
|
|
21
|
+
"atlas-ttps.json": "MITRE ATLAS TTPs (AML.T0xxx) cited by skills, with tactic, name, description. Pinned to ATLAS v2026.07 (May 2026).",
|
|
22
22
|
"d3fend-catalog.json": "MITRE D3FEND countermeasures (D3-xxx) keyed by id, with tactic + name. Pinned to D3FEND v1.3.0 release.",
|
|
23
23
|
"framework-control-gaps.json": "Per-control framework gap declarations: SI-2, A.8.8, PCI 6.3.3, etc. Each entry names the control, the lag, the evidence CVE, and remediation guidance.",
|
|
24
24
|
"global-frameworks.json": "Multi-jurisdiction framework registry: per-jurisdiction applicable frameworks × patch_sla / notification_sla / critical_controls / framework_gaps (jurisdiction count is reported by entry_count, not duplicated here). Cross-cutting authority for jurisdiction-clocks index.",
|
|
@@ -21,7 +21,7 @@ const RECIPES = [
|
|
|
21
21
|
when_to_use: "Before scoping or executing a red-team engagement against a model, agentic system, or AI feature.",
|
|
22
22
|
typical_jurisdictions: ["US", "EU", "UK", "GLOBAL"],
|
|
23
23
|
steps: [
|
|
24
|
-
{ skill: "ai-attack-surface", why: "Comprehensive attack-surface inventory mapped to ATLAS v2026.
|
|
24
|
+
{ skill: "ai-attack-surface", why: "Comprehensive attack-surface inventory mapped to ATLAS v2026.07 with gap flags." },
|
|
25
25
|
{ skill: "ai-c2-detection", why: "Detection coverage for AI-as-C2 (PROMPTFLUX / SesameOp / AI-API egress) before testing." },
|
|
26
26
|
{ skill: "mcp-agent-trust", why: "MCP server trust boundary for the engineering toolchain side of the surface." },
|
|
27
27
|
{ skill: "rag-pipeline-security", why: "RAG ingestion provenance + prompt-injection chain coverage." },
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EPSS score/percentile consistency gate.
|
|
6
|
+
*
|
|
7
|
+
* EPSS publishes a score and a percentile for every CVE, refreshed daily. The
|
|
8
|
+
* percentile is the score's RANK within that day's publication: if A scores
|
|
9
|
+
* higher than B on a given day, A's percentile is at least B's. That makes the
|
|
10
|
+
* pair self-checking with no network access — sort a day's entries by score and
|
|
11
|
+
* the percentiles must come out sorted too.
|
|
12
|
+
*
|
|
13
|
+
* The failure this catches is updating one field without the other. A refresh
|
|
14
|
+
* that writes a new score but keeps yesterday's percentile leaves a plausible
|
|
15
|
+
* entry: both numbers are in range, both look like EPSS values, and nothing
|
|
16
|
+
* downstream can tell they describe different days. The result is a CVE ranked
|
|
17
|
+
* as top-decile urgency on a score that no longer supports it, which is exactly
|
|
18
|
+
* the input the prioritisation model trusts most.
|
|
19
|
+
*
|
|
20
|
+
* What the ordering check does and does not establish. Monotonicity is a
|
|
21
|
+
* necessary condition, not a sufficient one: it catches a stale percentile that
|
|
22
|
+
* contradicts the new score's rank, which is what a partial refresh usually
|
|
23
|
+
* produces, but a stale value that happens to preserve the ordering passes.
|
|
24
|
+
* Proving two fields share a publication would require the fetched row, and
|
|
25
|
+
* that is not recoverable from the catalog offline. So this is a corruption
|
|
26
|
+
* detector, not a provenance proof — the provenance guarantee has to come from
|
|
27
|
+
* the write side, by fetching and writing score, percentile and date together.
|
|
28
|
+
*
|
|
29
|
+
* Three checks run:
|
|
30
|
+
* - within each epss_date, sorting by score must sort by percentile;
|
|
31
|
+
* - an entry carries both numeric fields or neither;
|
|
32
|
+
* - epss_note, which is derived text, must restate the current fields.
|
|
33
|
+
*
|
|
34
|
+
* Entries are grouped by epss_date so each publication is checked against
|
|
35
|
+
* itself; comparing across days is meaningless because the whole distribution
|
|
36
|
+
* shifts.
|
|
37
|
+
*
|
|
38
|
+
* Exit codes: 0 consistent, 1 inconsistent.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const fs = require("node:fs");
|
|
42
|
+
const path = require("node:path");
|
|
43
|
+
|
|
44
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Percentiles are published rounded to five decimals, so two scores that are
|
|
48
|
+
* adjacent in the ranking can round to percentiles that invert by a hair. That
|
|
49
|
+
* is arithmetic, not drift. Real mismatches are pairs pulled from different
|
|
50
|
+
* days, which land orders of magnitude above this: the widest rounding artifact
|
|
51
|
+
* observed in the catalog is 8e-5, while a stale-field mismatch runs 0.05-0.9.
|
|
52
|
+
* The threshold sits between the two, far enough from each that neither
|
|
53
|
+
* classification is a close call.
|
|
54
|
+
*/
|
|
55
|
+
const ROUNDING_TOLERANCE = 1e-3;
|
|
56
|
+
|
|
57
|
+
function loadCatalog(file) {
|
|
58
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ordinal(n) {
|
|
62
|
+
const suffixes = ["th", "st", "nd", "rd"];
|
|
63
|
+
const v = n % 100;
|
|
64
|
+
return `${n}${suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The operator-readable restatement of an entry's EPSS fields. Entries carry it
|
|
69
|
+
* as `epss_note`, and it is derived, not authored — so it must be rebuilt
|
|
70
|
+
* whenever the numbers move.
|
|
71
|
+
*/
|
|
72
|
+
function renderNote(entry) {
|
|
73
|
+
const pct = Math.round(entry.epss_percentile * 100);
|
|
74
|
+
return `FIRST EPSS ${entry.epss_score} (${ordinal(pct)} percentile) as of ${entry.epss_date}.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Group entries by publication date. Entries missing either field are reported
|
|
79
|
+
* separately rather than skipped — a half-populated pair is its own defect, and
|
|
80
|
+
* silently ignoring it would let the gate pass on the very shape it exists to
|
|
81
|
+
* catch.
|
|
82
|
+
*/
|
|
83
|
+
function cohorts(catalog) {
|
|
84
|
+
const groups = new Map();
|
|
85
|
+
const incomplete = [];
|
|
86
|
+
|
|
87
|
+
for (const [id, entry] of Object.entries(catalog)) {
|
|
88
|
+
const hasScore = typeof entry.epss_score === "number";
|
|
89
|
+
const hasPercentile = typeof entry.epss_percentile === "number";
|
|
90
|
+
|
|
91
|
+
if (!hasScore && !hasPercentile) continue;
|
|
92
|
+
if (!hasScore || !hasPercentile) {
|
|
93
|
+
incomplete.push({
|
|
94
|
+
id,
|
|
95
|
+
missing: hasScore ? "epss_percentile" : "epss_score",
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const date = entry.epss_date || "(no epss_date)";
|
|
101
|
+
if (!groups.has(date)) groups.set(date, []);
|
|
102
|
+
groups.get(date).push({ id, score: entry.epss_score, percentile: entry.epss_percentile });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { groups, incomplete };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sort a cohort by score and report every place the percentile goes backwards
|
|
110
|
+
* by more than rounding can explain.
|
|
111
|
+
*/
|
|
112
|
+
function inversions(rows) {
|
|
113
|
+
const sorted = [...rows].sort((a, b) => a.score - b.score);
|
|
114
|
+
const found = [];
|
|
115
|
+
|
|
116
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
117
|
+
const prev = sorted[i - 1];
|
|
118
|
+
const cur = sorted[i];
|
|
119
|
+
const delta = prev.percentile - cur.percentile;
|
|
120
|
+
if (delta > ROUNDING_TOLERANCE) {
|
|
121
|
+
found.push({ lower: cur, higher: prev, delta });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function check(catalogFile) {
|
|
129
|
+
const catalog = loadCatalog(catalogFile);
|
|
130
|
+
const { groups, incomplete } = cohorts(catalog);
|
|
131
|
+
const failures = [];
|
|
132
|
+
|
|
133
|
+
for (const [date, rows] of groups) {
|
|
134
|
+
for (const bad of inversions(rows)) {
|
|
135
|
+
failures.push(
|
|
136
|
+
`${date}: ${bad.lower.id} scores ${bad.lower.score} but ranks ` +
|
|
137
|
+
`${bad.lower.percentile}, below ${bad.higher.id} which scores ` +
|
|
138
|
+
`${bad.higher.score} at ${bad.higher.percentile} ` +
|
|
139
|
+
`(gap ${bad.delta.toFixed(5)}) — the two fields are from different publications`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const row of incomplete) {
|
|
145
|
+
failures.push(`${row.id}: has one EPSS field but not ${row.missing} — write both together or neither`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The prose restatement drifts the same way the percentile does: a refresh
|
|
149
|
+
// moves the numbers and leaves the sentence describing the previous
|
|
150
|
+
// publication, so the entry states two different scores as of two different
|
|
151
|
+
// dates. It is derived text, so it can be checked exactly.
|
|
152
|
+
for (const [id, entry] of Object.entries(catalog)) {
|
|
153
|
+
if (typeof entry.epss_note !== "string") continue;
|
|
154
|
+
if (typeof entry.epss_score !== "number" || typeof entry.epss_percentile !== "number") continue;
|
|
155
|
+
const expected = renderNote(entry);
|
|
156
|
+
if (entry.epss_note !== expected) {
|
|
157
|
+
failures.push(
|
|
158
|
+
`${id}: epss_note reads ${JSON.stringify(entry.epss_note)} but the fields say ` +
|
|
159
|
+
`${JSON.stringify(expected)} — regenerate the note when the numbers move`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { failures, cohortCount: groups.size, entryCount: [...groups.values()].reduce((n, r) => n + r.length, 0) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function main() {
|
|
168
|
+
const file = process.argv[2] || path.join(ROOT, "data", "cve-catalog.json");
|
|
169
|
+
const { failures, cohortCount, entryCount } = check(file);
|
|
170
|
+
|
|
171
|
+
if (failures.length) {
|
|
172
|
+
console.error("EPSS consistency: FAIL");
|
|
173
|
+
for (const f of failures) console.error(` ${f}`);
|
|
174
|
+
console.error(
|
|
175
|
+
`\n${failures.length} inconsistent ${failures.length === 1 ? "entry" : "entries"}. ` +
|
|
176
|
+
`Re-fetch score and percentile together from api.first.org and write them in one pass.`
|
|
177
|
+
);
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(
|
|
183
|
+
`EPSS consistency: PASS — ${entryCount} entries across ${cohortCount} publication ${cohortCount === 1 ? "date" : "dates"}, every cohort ranks monotonically`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (require.main === module) main();
|
|
188
|
+
|
|
189
|
+
module.exports = { check, cohorts, inversions, renderNote, ROUNDING_TOLERANCE };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TTP reference-integrity gate.
|
|
6
|
+
*
|
|
7
|
+
* data/attack-techniques.json and data/atlas-ttps.json are the pinned copies of
|
|
8
|
+
* ATT&CK and ATLAS. Every other file that names a technique — countermeasure
|
|
9
|
+
* maps, DLP controls, playbooks, skill bodies, CVE entries — is referring INTO
|
|
10
|
+
* those two catalogs. This gate proves those references resolve.
|
|
11
|
+
*
|
|
12
|
+
* The failure it exists for: MITRE retires and renumbers techniques between
|
|
13
|
+
* releases. When a pin is bumped, the two source catalogs get remapped, but
|
|
14
|
+
* references living in other files are easy to miss — nothing dereferences them
|
|
15
|
+
* at runtime, so a stale id keeps rendering in operator output as if it were
|
|
16
|
+
* current. It points at a MITRE page that no longer resolves, and any control
|
|
17
|
+
* claiming to counter it is now mapped to nothing (AGENTS.md Hard Rule #4: no
|
|
18
|
+
* orphaned controls). A bumped pin left exactly this residue in the D3FEND and
|
|
19
|
+
* DLP maps, invisible to every other gate.
|
|
20
|
+
*
|
|
21
|
+
* The check is offline: it resolves references against the pinned catalogs, not
|
|
22
|
+
* against MITRE. That is deliberate — scripts/check-ttp-upstream.js is the
|
|
23
|
+
* network check that asks whether the PINS are current, and it cannot block a
|
|
24
|
+
* release because it needs connectivity. This one can block, because a
|
|
25
|
+
* reference that does not resolve against the pin we ship is broken no matter
|
|
26
|
+
* what upstream says.
|
|
27
|
+
*
|
|
28
|
+
* Exit codes: 0 all references resolve, 1 unresolved references found.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require("node:fs");
|
|
32
|
+
const path = require("node:path");
|
|
33
|
+
|
|
34
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ATT&CK ids are TNNNN[.NNN]; ATLAS ids are AML.TNNNN[.NNN]. The lookbehind
|
|
38
|
+
* matters: without it the ATT&CK alternative matches the "T0017" inside
|
|
39
|
+
* "AML.T0017" and reports a phantom bare-ATT&CK reference for every ATLAS id in
|
|
40
|
+
* the tree. It covers the hyphen as well, because ATLAS ids also appear in
|
|
41
|
+
* filenames, where the dot is not a legal separator.
|
|
42
|
+
*/
|
|
43
|
+
const TTP_PATTERN = /\bAML\.T\d{4}(?:\.\d{3})?\b|(?<!AML[.-])\bT\d{4}(?:\.\d{3})?\b/g;
|
|
44
|
+
|
|
45
|
+
/** Files whose ids are definitions, not references. */
|
|
46
|
+
const SOURCE_CATALOGS = ["data/attack-techniques.json", "data/atlas-ttps.json"];
|
|
47
|
+
|
|
48
|
+
const SEARCH_ROOTS = ["data", "playbooks", "skills", "lib", "orchestrator", "bin"];
|
|
49
|
+
const SEARCH_EXTS = new Set([".json", ".md", ".js"]);
|
|
50
|
+
const SKIP_DIRS = new Set(["node_modules", "_indexes", "vendor", ".git"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tokens that match the pattern without being references to a technique.
|
|
54
|
+
* Every entry needs a reason: an unexplained allowlist is how a real stale id
|
|
55
|
+
* eventually gets parked here to make the gate green.
|
|
56
|
+
*/
|
|
57
|
+
const NOT_REFERENCES = [
|
|
58
|
+
{
|
|
59
|
+
id: "T1234",
|
|
60
|
+
file: "lib/gap-detectors.js",
|
|
61
|
+
why: "placeholder in a comment describing the reference-extraction pattern itself, not a claim about a technique",
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
function loadKnownIds() {
|
|
66
|
+
const known = new Set();
|
|
67
|
+
for (const rel of SOURCE_CATALOGS) {
|
|
68
|
+
const catalog = JSON.parse(fs.readFileSync(path.join(ROOT, rel), "utf8"));
|
|
69
|
+
for (const key of Object.keys(catalog)) {
|
|
70
|
+
if (key !== "_meta") known.add(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return known;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function* walk(dir) {
|
|
77
|
+
let names;
|
|
78
|
+
try {
|
|
79
|
+
names = fs.readdirSync(dir);
|
|
80
|
+
} catch {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
85
|
+
const full = path.join(dir, name);
|
|
86
|
+
let stat;
|
|
87
|
+
try {
|
|
88
|
+
stat = fs.statSync(full);
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (stat.isDirectory()) {
|
|
93
|
+
yield* walk(full);
|
|
94
|
+
} else if (SEARCH_EXTS.has(path.extname(name))) {
|
|
95
|
+
yield full;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function allowed(id, rel) {
|
|
101
|
+
return NOT_REFERENCES.some((e) => e.id === id && e.file === rel);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function scan(root = ROOT) {
|
|
105
|
+
const known = loadKnownIds();
|
|
106
|
+
const sources = new Set(SOURCE_CATALOGS);
|
|
107
|
+
const unresolved = new Map();
|
|
108
|
+
|
|
109
|
+
for (const dirName of SEARCH_ROOTS) {
|
|
110
|
+
const dir = path.join(root, dirName);
|
|
111
|
+
if (!fs.existsSync(dir)) continue;
|
|
112
|
+
|
|
113
|
+
for (const file of walk(dir)) {
|
|
114
|
+
const rel = path.relative(root, file).replace(/\\/g, "/");
|
|
115
|
+
if (sources.has(rel)) continue;
|
|
116
|
+
|
|
117
|
+
const text = fs.readFileSync(file, "utf8");
|
|
118
|
+
for (const id of text.match(TTP_PATTERN) || []) {
|
|
119
|
+
if (known.has(id) || allowed(id, rel)) continue;
|
|
120
|
+
if (!unresolved.has(id)) unresolved.set(id, new Set());
|
|
121
|
+
unresolved.get(id).add(rel);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { unresolved, knownCount: known.size };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function main() {
|
|
130
|
+
const { unresolved, knownCount } = scan();
|
|
131
|
+
|
|
132
|
+
if (unresolved.size) {
|
|
133
|
+
console.error("TTP reference integrity: FAIL");
|
|
134
|
+
for (const [id, files] of [...unresolved].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
135
|
+
console.error(` ${id} — not defined in the pinned catalogs`);
|
|
136
|
+
for (const f of [...files].sort()) console.error(` ${f}`);
|
|
137
|
+
}
|
|
138
|
+
console.error(
|
|
139
|
+
`\n${unresolved.size} unresolved ${unresolved.size === 1 ? "reference" : "references"}. ` +
|
|
140
|
+
`Either the id was retired upstream and these files still name it — repoint them at the ` +
|
|
141
|
+
`successor — or it is a technique the pinned catalogs do not carry yet, in which case import it.`
|
|
142
|
+
);
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(`TTP reference integrity: PASS — every referenced technique resolves against the ${knownCount} pinned ids`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (require.main === module) main();
|
|
151
|
+
|
|
152
|
+
module.exports = { scan, TTP_PATTERN, NOT_REFERENCES, loadKnownIds };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* scripts/check-ttp-upstream.js — validates every shipped TTP id against the
|
|
6
|
+
* pinned upstream release.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists. The catalogs in data/attack-techniques.json and
|
|
9
|
+
* data/atlas-ttps.json are curated mirrors of MITRE releases, and nothing
|
|
10
|
+
* checked that the ids in them are ids MITRE actually publishes. The ATLAS
|
|
11
|
+
* 2026.07 / ATT&CK 19.2 pin bump found eleven that were not: five ATT&CK
|
|
12
|
+
* techniques revoked upstream — already revoked in 19.1, the version pinned at
|
|
13
|
+
* the time, so they had been shipping revoked for a full release cycle — and
|
|
14
|
+
* six that appear in no upstream domain at any version. Every one of them was
|
|
15
|
+
* reachable by a skill telling an operator to map a finding to it, which is
|
|
16
|
+
* exactly what Hard Rule #4 (no orphaned controls — every control maps to a
|
|
17
|
+
* real TTP) exists to prevent.
|
|
18
|
+
*
|
|
19
|
+
* This is a NETWORK check, so it runs in the refresh workflow beside the pin
|
|
20
|
+
* checker rather than in predeploy, which is offline. Report-only: it prints
|
|
21
|
+
* findings and exits non-zero, leaving the decision to a maintainer, because a
|
|
22
|
+
* revocation needs a judgement call about the successor rather than an
|
|
23
|
+
* automatic rewrite.
|
|
24
|
+
*
|
|
25
|
+
* node scripts/check-ttp-upstream.js validate against the pins
|
|
26
|
+
* node scripts/check-ttp-upstream.js --json machine-readable output
|
|
27
|
+
*
|
|
28
|
+
* Exit: 0 clean, 1 findings, 2 upstream unreachable (never a silent pass —
|
|
29
|
+
* an unreachable upstream must not read as "all ids valid").
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require("node:fs");
|
|
33
|
+
const path = require("node:path");
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
36
|
+
const UA = "exceptd-ttp-validator (+https://exceptd.com)";
|
|
37
|
+
const ATTACK_DOMAINS = ["enterprise-attack", "ics-attack", "mobile-attack"];
|
|
38
|
+
|
|
39
|
+
function readJson(p) {
|
|
40
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, p), "utf8"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A pinned version read out of a catalog is file data, and it is about to be
|
|
45
|
+
* interpolated into an upstream URL. Constrain it to the exact shape each
|
|
46
|
+
* project publishes before it can reach a request, so a malformed or tampered
|
|
47
|
+
* `_meta` cannot steer the fetch — and so a typo'd pin fails here with a clear
|
|
48
|
+
* message instead of as a puzzling 404 later.
|
|
49
|
+
*
|
|
50
|
+
* ATT&CK semver-ish major.minor 19.2
|
|
51
|
+
* ATLAS CalVer YYYY.MM with optional .N 2026.07, 2025.11.2
|
|
52
|
+
*/
|
|
53
|
+
const VERSION_SHAPES = {
|
|
54
|
+
attack: /^\d{1,3}\.\d{1,3}$/,
|
|
55
|
+
atlas: /^\d{4}\.\d{2}(\.\d{1,2})?$/,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function assertPinShape(kind, value) {
|
|
59
|
+
if (typeof value !== "string" || !VERSION_SHAPES[kind].test(value)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${kind} pin ${JSON.stringify(value)} does not match ${VERSION_SHAPES[kind]} — ` +
|
|
62
|
+
`refusing to build an upstream URL from it`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function fetchJson(url) {
|
|
69
|
+
const res = await fetch(url, { headers: { "user-agent": UA } });
|
|
70
|
+
if (!res.ok) throw new Error(`${res.status} ${url}`);
|
|
71
|
+
return JSON.parse(await res.text());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchText(url) {
|
|
75
|
+
const res = await fetch(url, { headers: { "user-agent": UA } });
|
|
76
|
+
if (!res.ok) throw new Error(`${res.status} ${url}`);
|
|
77
|
+
return res.text();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Every ATT&CK technique id in the pinned release, with its revocation state. */
|
|
81
|
+
async function liveAttack(rawVersion) {
|
|
82
|
+
const version = encodeURIComponent(assertPinShape("attack", rawVersion));
|
|
83
|
+
const out = new Map();
|
|
84
|
+
for (const dom of ATTACK_DOMAINS) {
|
|
85
|
+
const j = await fetchJson(
|
|
86
|
+
`https://raw.githubusercontent.com/mitre-attack/attack-stix-data/v${version}/${dom}/${dom}.json`
|
|
87
|
+
);
|
|
88
|
+
const byId = new Map(j.objects.map((o) => [o.id, o]));
|
|
89
|
+
for (const o of j.objects) {
|
|
90
|
+
if (o.type !== "attack-pattern") continue;
|
|
91
|
+
const ext = (o.external_references || []).find(
|
|
92
|
+
(r) => r.source_name && r.source_name.includes("attack")
|
|
93
|
+
);
|
|
94
|
+
if (!ext || !ext.external_id) continue;
|
|
95
|
+
let supersededBy = null;
|
|
96
|
+
if (o.revoked) {
|
|
97
|
+
const rel = j.objects.find(
|
|
98
|
+
(r) => r.type === "relationship" && r.relationship_type === "revoked-by" && r.source_ref === o.id
|
|
99
|
+
);
|
|
100
|
+
const tgt = rel && byId.get(rel.target_ref);
|
|
101
|
+
const te = tgt && (tgt.external_references || []).find(
|
|
102
|
+
(r) => r.source_name && r.source_name.includes("attack")
|
|
103
|
+
);
|
|
104
|
+
supersededBy = te ? te.external_id : null;
|
|
105
|
+
}
|
|
106
|
+
out.set(ext.external_id, {
|
|
107
|
+
revoked: !!o.revoked,
|
|
108
|
+
deprecated: !!o.x_mitre_deprecated,
|
|
109
|
+
supersededBy,
|
|
110
|
+
name: o.name,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every ATLAS id in the pinned content release. */
|
|
118
|
+
async function liveAtlas(rawVersion) {
|
|
119
|
+
const version = encodeURIComponent(assertPinShape("atlas", rawVersion));
|
|
120
|
+
const yaml = await fetchText(
|
|
121
|
+
`https://raw.githubusercontent.com/mitre-atlas/atlas-data/v${version}/dist/v6/ATLAS-${version}.yaml`
|
|
122
|
+
);
|
|
123
|
+
// The dist bundle is a mapping keyed by id, each block repeating `id:`.
|
|
124
|
+
return new Set(
|
|
125
|
+
(yaml.match(/^\s*id:\s*(AML\.[A-Za-z]+[0-9.]*)\s*$/gm) || []).map((l) =>
|
|
126
|
+
l.replace(/.*id:\s*/, "").trim()
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function main() {
|
|
132
|
+
const asJson = process.argv.includes("--json");
|
|
133
|
+
const attackCat = readJson("data/attack-techniques.json");
|
|
134
|
+
const atlasCat = readJson("data/atlas-ttps.json");
|
|
135
|
+
const attackVersion = attackCat._meta.attack_version;
|
|
136
|
+
const atlasVersion = atlasCat._meta.atlas_version;
|
|
137
|
+
|
|
138
|
+
let attack;
|
|
139
|
+
let atlas;
|
|
140
|
+
try {
|
|
141
|
+
[attack, atlas] = await Promise.all([liveAttack(attackVersion), liveAtlas(atlasVersion)]);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
process.stderr.write(
|
|
144
|
+
`[check-ttp-upstream] UNREACHABLE — could not fetch upstream (${err.message}). ` +
|
|
145
|
+
`Not treating this as a pass: an unreachable upstream proves nothing about the shipped ids.\n`
|
|
146
|
+
);
|
|
147
|
+
process.exitCode = 2;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (attack.size === 0 || atlas.size === 0) {
|
|
152
|
+
process.stderr.write("[check-ttp-upstream] UNREACHABLE — upstream returned an empty id set.\n");
|
|
153
|
+
process.exitCode = 2;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const findings = [];
|
|
158
|
+
for (const id of Object.keys(attackCat)) {
|
|
159
|
+
if (id === "_meta") continue;
|
|
160
|
+
const live = attack.get(id);
|
|
161
|
+
if (!live) {
|
|
162
|
+
findings.push({ catalog: "attack", id, issue: "absent-upstream", detail: `not published in ATT&CK v${attackVersion} in any domain` });
|
|
163
|
+
} else if (live.revoked) {
|
|
164
|
+
findings.push({ catalog: "attack", id, issue: "revoked", detail: `revoked upstream${live.supersededBy ? ` — superseded by ${live.supersededBy}` : ""}` });
|
|
165
|
+
} else if (live.deprecated) {
|
|
166
|
+
findings.push({ catalog: "attack", id, issue: "deprecated", detail: "deprecated upstream" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const id of Object.keys(atlasCat)) {
|
|
170
|
+
if (id === "_meta") continue;
|
|
171
|
+
if (!atlas.has(id)) {
|
|
172
|
+
findings.push({ catalog: "atlas", id, issue: "absent-upstream", detail: `not published in ATLAS v${atlasVersion}` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (asJson) {
|
|
177
|
+
process.stdout.write(JSON.stringify({ attackVersion, atlasVersion, findings }, null, 2) + "\n");
|
|
178
|
+
} else {
|
|
179
|
+
process.stdout.write(
|
|
180
|
+
`[check-ttp-upstream] ATT&CK v${attackVersion} (${attack.size} live ids) · ATLAS v${atlasVersion} (${atlas.size} live ids)\n`
|
|
181
|
+
);
|
|
182
|
+
if (findings.length === 0) {
|
|
183
|
+
process.stdout.write("[check-ttp-upstream] ok — every shipped TTP id exists upstream and is neither revoked nor deprecated.\n");
|
|
184
|
+
} else {
|
|
185
|
+
for (const f of findings) {
|
|
186
|
+
process.stdout.write(` ✗ ${f.catalog} ${f.id}: ${f.detail}\n`);
|
|
187
|
+
}
|
|
188
|
+
process.stdout.write(
|
|
189
|
+
`[check-ttp-upstream] ${findings.length} finding(s). A revocation needs a maintainer decision on the ` +
|
|
190
|
+
`successor — remap the id and carry its cve_refs across rather than deleting the entry.\n`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
process.exitCode = findings.length ? 1 : 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (require.main === module) main().catch((err) => {
|
|
198
|
+
process.stderr.write(`[check-ttp-upstream] ERROR ${err && err.stack ? err.stack : err}\n`);
|
|
199
|
+
process.exitCode = 2;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
module.exports = { VERSION_SHAPES, assertPinShape };
|
|
@@ -104,18 +104,32 @@ const COMMENT_EXEMPT = new Set([
|
|
|
104
104
|
// need to name individual local-only files. Untracked-but-NOT-ignored files
|
|
105
105
|
// ARE still scanned: a new file a contributor is about to commit is exactly
|
|
106
106
|
// what the gate must catch. Computed via `git check-ignore` over the walked set.
|
|
107
|
+
// Returns the ignored subset, or NULL when git cannot answer.
|
|
108
|
+
//
|
|
109
|
+
// "No path matched" and "the question could not be asked" are different
|
|
110
|
+
// results and must not collapse into the same empty set. Without a repository
|
|
111
|
+
// — a build context that omits .git/, or git not installed — an empty set
|
|
112
|
+
// silently reclassifies every local-only file as part of the shipped surface,
|
|
113
|
+
// so the gate reports violations in files a clone never contains. Returning
|
|
114
|
+
// null lets the caller say it could not determine the surface instead of
|
|
115
|
+
// asserting a wrong one.
|
|
107
116
|
function gitIgnoredSet(relPaths) {
|
|
108
117
|
if (!relPaths.length) return new Set();
|
|
109
118
|
try {
|
|
110
119
|
const out = execFileSync("git", ["check-ignore", "--stdin"], {
|
|
111
120
|
cwd: ROOT, input: relPaths.join("\n"), encoding: "utf8", maxBuffer: 64 * 1024 * 1024,
|
|
121
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
112
122
|
});
|
|
113
123
|
return new Set(out.split(/\r?\n/).filter(Boolean));
|
|
114
124
|
} catch (e) {
|
|
115
|
-
//
|
|
116
|
-
//
|
|
125
|
+
// Exit 1 with no stderr is git's way of saying "no path matched" — a real
|
|
126
|
+
// answer, and an empty set is correct. Anything else (git missing, not a
|
|
127
|
+
// repository, .git absent) means the question went unanswered.
|
|
128
|
+
const status = e && typeof e.status === "number" ? e.status : null;
|
|
129
|
+
const stderr = e && e.stderr ? String(e.stderr).trim() : "";
|
|
117
130
|
const out = e && e.stdout ? String(e.stdout) : "";
|
|
118
|
-
return new Set(out.split(/\r?\n/).filter(Boolean));
|
|
131
|
+
if (status === 1 && !stderr) return new Set(out.split(/\r?\n/).filter(Boolean));
|
|
132
|
+
return null;
|
|
119
133
|
}
|
|
120
134
|
}
|
|
121
135
|
|
|
@@ -185,12 +199,16 @@ function countLineViolations(rel) {
|
|
|
185
199
|
function scanCurrent() {
|
|
186
200
|
const files = walk(ROOT);
|
|
187
201
|
const ignored = gitIgnoredSet(files);
|
|
202
|
+
// Without git the shipped surface is unknowable: local-only files are
|
|
203
|
+
// indistinguishable from tracked ones, so any result would be a guess.
|
|
204
|
+
// Report that rather than emit findings the baseline cannot be compared to.
|
|
205
|
+
if (ignored === null) return { byFile: {}, filenameViolations: [], surfaceUnknown: true };
|
|
188
206
|
const byFile = {};
|
|
189
207
|
const filenameViolations = [];
|
|
190
208
|
for (const rel of files) {
|
|
191
|
-
// Skip git-ignored, local-only files
|
|
192
|
-
//
|
|
193
|
-
//
|
|
209
|
+
// Skip git-ignored, local-only files that `git clone` never ships.
|
|
210
|
+
// Untracked-but-not-ignored files are still scanned — a new file about to
|
|
211
|
+
// be committed is exactly what the gate guards.
|
|
194
212
|
if (ignored.has(rel)) continue;
|
|
195
213
|
if (FILENAME_VERSION_RE.test(rel)) filenameViolations.push(rel);
|
|
196
214
|
const n = countLineViolations(rel);
|
|
@@ -229,6 +247,32 @@ function main() {
|
|
|
229
247
|
const wantUpdate = process.argv.includes("--update-baseline");
|
|
230
248
|
const current = scanCurrent();
|
|
231
249
|
|
|
250
|
+
if (current.surfaceUnknown) {
|
|
251
|
+
// Never write a baseline from a scan that could not tell shipped files from
|
|
252
|
+
// local ones — that would bake the wrong surface in permanently.
|
|
253
|
+
//
|
|
254
|
+
// Automation is the one place this must not degrade to a skip. This gate
|
|
255
|
+
// runs inside predeploy, and predeploy guards the publish job, so a
|
|
256
|
+
// silently-skipped run there stops enforcing on exactly the path that
|
|
257
|
+
// ships. Locally — a container built without .git, a tarball inspection —
|
|
258
|
+
// skipping is the honest answer, because the shipped surface genuinely is
|
|
259
|
+
// not knowable there and failing would only punish the harness.
|
|
260
|
+
const inAutomation = process.env.CI === "true" || !!process.env.GITHUB_ACTIONS;
|
|
261
|
+
if (inAutomation) {
|
|
262
|
+
console.error("[check-version-tags] FAIL — no git repository available, so the shipped");
|
|
263
|
+
console.error(" surface cannot be determined. In automation this is a failure, not a skip:");
|
|
264
|
+
console.error(" this gate runs inside predeploy, which guards publishing. Ensure the job");
|
|
265
|
+
console.error(" checks out git metadata (actions/checkout provides it by default).");
|
|
266
|
+
process.exitCode = 2;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
console.error("[check-version-tags] SKIPPED — no git repository available, so the shipped");
|
|
270
|
+
console.error(" surface cannot be determined. This is not a pass; run it where git metadata");
|
|
271
|
+
console.error(" is present. In CI the same condition fails instead.");
|
|
272
|
+
process.exitCode = 0;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
232
276
|
if (wantUpdate) {
|
|
233
277
|
writeBaseline(current);
|
|
234
278
|
process.exitCode = 0;
|