@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.
Files changed (71) hide show
  1. package/AGENTS.md +5 -5
  2. package/ARCHITECTURE.md +3 -3
  3. package/CHANGELOG.md +21 -1
  4. package/CONTEXT.md +6 -6
  5. package/README.md +6 -6
  6. package/data/_indexes/_meta.json +46 -46
  7. package/data/_indexes/activity-feed.json +17 -17
  8. package/data/_indexes/catalog-summaries.json +10 -10
  9. package/data/_indexes/chains.json +2890 -2798
  10. package/data/_indexes/frequency.json +12 -8
  11. package/data/_indexes/recipes.json +1 -1
  12. package/data/_indexes/section-offsets.json +165 -165
  13. package/data/_indexes/summary-cards.json +4 -4
  14. package/data/_indexes/token-budget.json +68 -68
  15. package/data/_indexes/xref.json +2 -2
  16. package/data/atlas-ttps.json +140 -79
  17. package/data/attack-techniques.json +22 -209
  18. package/data/cve-catalog.json +440 -332
  19. package/data/d3fend-catalog.json +5 -5
  20. package/data/dlp-controls.json +1 -1
  21. package/data/framework-control-gaps.json +8 -8
  22. package/data/playbooks/audit-log-integrity.json +2 -2
  23. package/data/playbooks/hardening.json +1 -1
  24. package/data/zeroday-lessons.json +2 -2
  25. package/manifest-snapshot.json +8 -8
  26. package/manifest-snapshot.sha256 +1 -1
  27. package/manifest.json +95 -95
  28. package/package.json +2 -2
  29. package/sbom.cdx.json +166 -121
  30. package/scripts/builders/catalog-summaries.js +1 -1
  31. package/scripts/builders/recipes.js +1 -1
  32. package/scripts/check-epss-consistency.js +189 -0
  33. package/scripts/check-ttp-references.js +152 -0
  34. package/scripts/check-ttp-upstream.js +202 -0
  35. package/scripts/check-version-tags.js +50 -6
  36. package/scripts/predeploy.js +26 -0
  37. package/skills/age-gates-child-safety/skill.md +2 -2
  38. package/skills/ai-attack-surface/skill.md +3 -3
  39. package/skills/ai-c2-detection/skill.md +4 -4
  40. package/skills/api-security/skill.md +1 -1
  41. package/skills/attack-surface-pentest/skill.md +3 -3
  42. package/skills/audit-log-integrity/skill.md +3 -3
  43. package/skills/cloud-iam-incident/skill.md +1 -1
  44. package/skills/cloud-security/skill.md +2 -2
  45. package/skills/compliance-theater/skill.md +2 -2
  46. package/skills/container-runtime-security/skill.md +2 -2
  47. package/skills/coordinated-vuln-disclosure/skill.md +1 -1
  48. package/skills/defensive-countermeasure-mapping/skill.md +1 -1
  49. package/skills/dlp-gap-analysis/skill.md +4 -4
  50. package/skills/exploit-scoring/skill.md +1 -1
  51. package/skills/framework-gap-analysis/skill.md +3 -3
  52. package/skills/fuzz-testing-strategy/skill.md +1 -1
  53. package/skills/incident-response-playbook/skill.md +7 -7
  54. package/skills/mcp-agent-trust/skill.md +1 -1
  55. package/skills/mlops-security/skill.md +3 -3
  56. package/skills/ot-ics-security/skill.md +5 -5
  57. package/skills/policy-exception-gen/skill.md +2 -2
  58. package/skills/pqc-first/skill.md +1 -1
  59. package/skills/rag-pipeline-security/skill.md +4 -4
  60. package/skills/ransomware-response/skill.md +2 -2
  61. package/skills/sector-energy/skill.md +9 -9
  62. package/skills/sector-federal-government/skill.md +1 -1
  63. package/skills/sector-financial/skill.md +3 -3
  64. package/skills/sector-healthcare/skill.md +2 -2
  65. package/skills/security-maturity-tiers/skill.md +1 -1
  66. package/skills/skill-update-loop/skill.md +5 -5
  67. package/skills/supply-chain-integrity/skill.md +1 -1
  68. package/skills/threat-model-currency/skill.md +7 -7
  69. package/skills/threat-modeling-methodology/skill.md +1 -1
  70. package/skills/webapp-security/skill.md +1 -1
  71. 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.06 (May 2026).",
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.06 with gap flags." },
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
- // `git check-ignore --stdin` exits 1 when NO path is ignored (not an
116
- // error); any paths it did match are on stdout. Absent that, none ignored.
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 (a contributor's private working notes
192
- // that `git clone` never ships). Untracked-but-not-ignored files are still
193
- // scanned — a new file about to be committed is what the gate guards.
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;