@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.0
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 +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -2
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Revision-pinned NixOS / Home Manager option catalog (NIX-002).
|
|
2
|
+
//
|
|
3
|
+
// This is NOT a copy of the option set. It lists the options the scanner reasons
|
|
4
|
+
// about, with a type, a default PER RELEASE, and the renames that must be followed
|
|
5
|
+
// so an old spelling cannot bypass a hardening rule that is written against the new
|
|
6
|
+
// one. Anything absent from here is "uncataloged" and its default is unknown, never
|
|
7
|
+
// guessed. A default is only "proven" when it is recorded for the release in force
|
|
8
|
+
// (or recorded identically for every release in the catalog).
|
|
9
|
+
//
|
|
10
|
+
// The catalog is pinned by NixOS release (the CI-tested set in CATALOG_RELEASES) and
|
|
11
|
+
// by a content hash (`catalogRevision`), so a report can say exactly which table it
|
|
12
|
+
// was judged against. Refresh it, and bump CATALOG_SCHEMA when the shape changes, by
|
|
13
|
+
// editing the tables below: the revision hash changes with them.
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export const CATALOG_SCHEMA = 1;
|
|
18
|
+
export const CATALOG_RELEASES = Object.freeze(['24.11', '25.05', '25.11']);
|
|
19
|
+
|
|
20
|
+
const same = (v) => Object.freeze(Object.fromEntries(CATALOG_RELEASES.map((r) => [r, v])));
|
|
21
|
+
|
|
22
|
+
/** option path -> { type, default?: {release: value} }. A missing `default` means "no static default" (unknown). */
|
|
23
|
+
const NIXOS_OPTIONS = {
|
|
24
|
+
'services.openssh.enable': { type: 'bool', default: same(false) },
|
|
25
|
+
'services.openssh.openFirewall': { type: 'bool', default: same(true) },
|
|
26
|
+
'services.openssh.ports': { type: 'listOf', default: same([22]) },
|
|
27
|
+
'services.openssh.settings.PermitRootLogin': { type: 'enum', default: same('prohibit-password') },
|
|
28
|
+
'services.openssh.settings.PasswordAuthentication': { type: 'bool', default: same(true) },
|
|
29
|
+
'services.openssh.settings.KbdInteractiveAuthentication': { type: 'bool', default: same(true) },
|
|
30
|
+
'services.openssh.settings.X11Forwarding': { type: 'bool', default: same(false) },
|
|
31
|
+
'networking.firewall.enable': { type: 'bool', default: same(true) },
|
|
32
|
+
'networking.firewall.allowedTCPPorts': { type: 'listOf', default: same([]) },
|
|
33
|
+
'networking.firewall.allowedUDPPorts': { type: 'listOf', default: same([]) },
|
|
34
|
+
'networking.hostName': { type: 'str', default: same('nixos') },
|
|
35
|
+
'security.sudo.enable': { type: 'bool', default: same(true) },
|
|
36
|
+
'security.sudo.wheelNeedsPassword': { type: 'bool', default: same(true) },
|
|
37
|
+
'security.sudo.extraConfig': { type: 'lines', default: same('') },
|
|
38
|
+
'security.sudo.execWheelOnly': { type: 'bool', default: same(false) },
|
|
39
|
+
'networking.firewall.allowedTCPPortRanges': { type: 'listOf', default: same([]) },
|
|
40
|
+
'users.mutableUsers': { type: 'bool', default: same(true) },
|
|
41
|
+
'nix.settings.sandbox': { type: 'bool', default: same(true) },
|
|
42
|
+
'nix.settings.trusted-users': { type: 'listOf', default: same(['root']) },
|
|
43
|
+
'services.nginx.enable': { type: 'bool', default: same(false) },
|
|
44
|
+
'services.postgresql.enable': { type: 'bool', default: same(false) },
|
|
45
|
+
'services.postgresql.enableTCPIP': { type: 'bool', default: same(false) },
|
|
46
|
+
'services.xserver.enable': { type: 'bool', default: same(false) },
|
|
47
|
+
'system.stateVersion': { type: 'str' },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const HM_OPTIONS = {
|
|
51
|
+
'programs.git.enable': { type: 'bool', default: same(false) },
|
|
52
|
+
'programs.bash.enable': { type: 'bool', default: same(false) },
|
|
53
|
+
'services.gpg-agent.enable': { type: 'bool', default: same(false) },
|
|
54
|
+
'home.stateVersion': { type: 'str' },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** old spelling -> current spelling. A definition at the old path is evaluated as the new path. */
|
|
58
|
+
const NIXOS_RENAMES = {
|
|
59
|
+
'services.openssh.permitRootLogin': 'services.openssh.settings.PermitRootLogin',
|
|
60
|
+
'services.openssh.passwordAuthentication': 'services.openssh.settings.PasswordAuthentication',
|
|
61
|
+
'services.openssh.kbdInteractiveAuthentication': 'services.openssh.settings.KbdInteractiveAuthentication',
|
|
62
|
+
'services.openssh.challengeResponseAuthentication': 'services.openssh.settings.KbdInteractiveAuthentication',
|
|
63
|
+
'services.openssh.forwardX11': 'services.openssh.settings.X11Forwarding',
|
|
64
|
+
'nix.useSandbox': 'nix.settings.sandbox',
|
|
65
|
+
'nix.trustedUsers': 'nix.settings.trusted-users',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Prefixes the catalog is authoritative for: an option under one that is neither listed nor renamed is "unknown", not "fine". */
|
|
69
|
+
const AUTHORITATIVE = ['services.openssh', 'networking.firewall', 'security.sudo'];
|
|
70
|
+
/** Freeform attribute sets: any key is valid, but only the listed ones have a known default. */
|
|
71
|
+
const FREEFORM = ['services.openssh.settings', 'nix.settings', 'networking.firewall.interfaces'];
|
|
72
|
+
|
|
73
|
+
const CATALOG = {
|
|
74
|
+
schema: CATALOG_SCHEMA,
|
|
75
|
+
releases: [...CATALOG_RELEASES],
|
|
76
|
+
nixos: { options: NIXOS_OPTIONS, renames: NIXOS_RENAMES, authoritative: AUTHORITATIVE, freeform: FREEFORM },
|
|
77
|
+
homeManager: { options: HM_OPTIONS, renames: {}, authoritative: [], freeform: [] },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function computeCatalogRevision(catalog) {
|
|
81
|
+
const { revision, ...content } = catalog;
|
|
82
|
+
void revision;
|
|
83
|
+
return `nixos-option-catalog/${catalog.schema}+${createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 12)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const CATALOG_REVISION = computeCatalogRevision(CATALOG);
|
|
87
|
+
|
|
88
|
+
export function defaultCatalog() {
|
|
89
|
+
return { ...CATALOG, revision: CATALOG_REVISION };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `nixos-24.11`, `release-25.05`, `24.11` -> '24.11'; anything else null. */
|
|
93
|
+
export function normalizeRelease(text) {
|
|
94
|
+
const m = /(?:^|[^0-9])(\d{2}\.(?:05|11))(?![0-9])/.exec(String(text || ''));
|
|
95
|
+
return m ? m[1] : null;
|
|
96
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Inline suppression pragmas for Haskell and Nix (X-013).
|
|
2
|
+
//
|
|
3
|
+
// Haskell `-- agentic-security-ignore: rule-a, rule-b` and `{- agentic-security-ignore: rule-a -}`
|
|
4
|
+
// Nix `# agentic-security-ignore: rule-a` and `/* agentic-security-ignore: rule-a */`
|
|
5
|
+
//
|
|
6
|
+
// A pragma is recognised only INSIDE A COMMENT, so the same words in a string literal, an identifier or an operator
|
|
7
|
+
// (`-->` is an operator in Haskell, not a comment) never suppress anything. Comment boundaries come from a small
|
|
8
|
+
// scanner that knows each language's strings, nesting and interpolation, not from a regular expression over the line.
|
|
9
|
+
//
|
|
10
|
+
// Scope: LINE. A pragma applies to findings reported on the line it sits on, exactly like the pragmas of the other
|
|
11
|
+
// languages. With rule ids it suppresses only a finding whose rule, id, family, CWE or title slug EQUALS one of them
|
|
12
|
+
// (exact token match, never a substring: `cmd` must not silence `cmdi`). A bare pragma suppresses every finding on its
|
|
13
|
+
// line. A pragma whose rule list is malformed (anything other than ids separated by commas or spaces) suppresses
|
|
14
|
+
// nothing and is reported back as `malformed`, so it cannot silence an unrelated finding by accident.
|
|
15
|
+
|
|
16
|
+
const MARK = /agentic-security-ignore(?![A-Za-z0-9_-])/;
|
|
17
|
+
const RULE_ID = /^[A-Za-z0-9_.:/\-]+$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Comment spans of a Haskell source: [{start, end, text}] in source offsets.
|
|
21
|
+
* Strings ("..." with escapes, string gaps) and character literals are skipped; `--` starts a line comment only when
|
|
22
|
+
* it is not part of a longer operator symbol; `{- -}` nests.
|
|
23
|
+
*/
|
|
24
|
+
export function haskellComments(src) {
|
|
25
|
+
const out = []; const n = src.length; let i = 0;
|
|
26
|
+
const SYM = /[!#$%&*+./<=>?@\\^|~:-]/;
|
|
27
|
+
while (i < n) {
|
|
28
|
+
const c = src[i]; const d = src[i + 1];
|
|
29
|
+
if (c === '"') { i++; while (i < n && src[i] !== '"') { if (src[i] === '\\') i++; if (src[i] === '\n') break; i++; } i++; continue; }
|
|
30
|
+
if (c === "'" && /^'(?:[^'\\\n]|\\[^\n]{1,8}?)'/.test(src.slice(i, i + 12)) && !/[A-Za-z0-9_']/.test(src[i - 1] || '')) { const m = /^'(?:[^'\\\n]|\\[^\n]{1,8}?)'/.exec(src.slice(i, i + 12)); i += m[0].length; continue; }
|
|
31
|
+
if (c === '{' && d === '-' && src[i + 2] !== '#') {
|
|
32
|
+
let depth = 1; let j = i + 2;
|
|
33
|
+
while (j < n && depth > 0) { if (src[j] === '{' && src[j + 1] === '-') { depth++; j += 2; } else if (src[j] === '-' && src[j + 1] === '}') { depth--; j += 2; } else j++; }
|
|
34
|
+
out.push({ start: i, end: j, text: src.slice(i, j) }); i = j; continue;
|
|
35
|
+
}
|
|
36
|
+
if (c === '-' && d === '-') {
|
|
37
|
+
let j = i; while (src[j] === '-') j++;
|
|
38
|
+
const before = src[i - 1]; const after = src[j];
|
|
39
|
+
if (!(before && SYM.test(before)) && !(after && SYM.test(after)) ) { const e = src.indexOf('\n', i); const end = e < 0 ? n : e; out.push({ start: i, end, text: src.slice(i, end) }); i = end; continue; }
|
|
40
|
+
i = j; continue;
|
|
41
|
+
}
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Comment spans of a Nix source: `#` to end of line, and slash-star block comments. Strings `"..."` and indented strings `''...''` are
|
|
49
|
+
* skipped, including their `${ ... }` interpolations, whose inner code may itself contain comments and strings.
|
|
50
|
+
*/
|
|
51
|
+
export function nixComments(src) {
|
|
52
|
+
const out = []; const n = src.length;
|
|
53
|
+
function scan(i, stopAtBrace) {
|
|
54
|
+
let depth = 0;
|
|
55
|
+
while (i < n) {
|
|
56
|
+
const c = src[i]; const d = src[i + 1];
|
|
57
|
+
if (stopAtBrace) { if (c === '{') depth++; else if (c === '}') { if (depth === 0) return i + 1; depth--; } }
|
|
58
|
+
if (c === '#') { const e = src.indexOf('\n', i); const end = e < 0 ? n : e; out.push({ start: i, end, text: src.slice(i, end) }); i = end; continue; }
|
|
59
|
+
if (c === '/' && d === '*') { const e = src.indexOf('*/', i + 2); const end = e < 0 ? n : e + 2; out.push({ start: i, end, text: src.slice(i, end) }); i = end; continue; }
|
|
60
|
+
if (c === '"') { i = skipString(i + 1, '"'); continue; }
|
|
61
|
+
if (c === "'" && d === "'") { i = skipString(i + 2, "''"); continue; }
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
return i;
|
|
65
|
+
}
|
|
66
|
+
function skipString(i, close) {
|
|
67
|
+
while (i < n) {
|
|
68
|
+
const c = src[i]; const d = src[i + 1];
|
|
69
|
+
if (close === '"') { if (c === '\\') { i += 2; continue; } if (c === '"') return i + 1; }
|
|
70
|
+
else {
|
|
71
|
+
if (c === "'" && d === "'") { const e = src[i + 2]; if (e === "'" || e === '$') { i += 3; continue; } if (e === '\\') { i += 4; continue; } return i + 2; }
|
|
72
|
+
}
|
|
73
|
+
if (c === '$' && d === '{') { i = scan(i + 2, true); continue; }
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
return i;
|
|
77
|
+
}
|
|
78
|
+
scan(0, false);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const lineStarts = (src) => { const a = [0]; for (let i = 0; i < src.length; i++) if (src[i] === '\n') a.push(i + 1); return a; };
|
|
83
|
+
const lineOfOffset = (starts, off) => { let lo = 0; let hi = starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; } return lo + 1; };
|
|
84
|
+
|
|
85
|
+
/** Strips the comment delimiters from a comment's text. */
|
|
86
|
+
function commentBody(text) {
|
|
87
|
+
if (text.startsWith('{-')) return text.replace(/^\{-+/, '').replace(/-+\}$/, '');
|
|
88
|
+
if (text.startsWith('/*')) return text.replace(/^\/\*+/, '').replace(/\*+\/$/, '');
|
|
89
|
+
if (text.startsWith('#')) return text.replace(/^#+/, '');
|
|
90
|
+
return text.replace(/^--+/, '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parses the pragma inside ONE comment body. Returns null when there is none, else
|
|
95
|
+
* {bare:boolean, rules:string[], malformed:string|null}.
|
|
96
|
+
*/
|
|
97
|
+
export function parsePragma(body) {
|
|
98
|
+
const m = MARK.exec(body);
|
|
99
|
+
if (!m) return null;
|
|
100
|
+
let rest = body.slice(m.index + m[0].length);
|
|
101
|
+
// only the first line of a multi-line comment body carries the list
|
|
102
|
+
rest = rest.split('\n')[0];
|
|
103
|
+
// a trailing block-comment closer is not part of the list
|
|
104
|
+
rest = rest.replace(/(?:-\}|\*\/)\s*$/, '');
|
|
105
|
+
const hasColon = /^\s*:/.test(rest);
|
|
106
|
+
rest = rest.replace(/^\s*:?/, '').trim();
|
|
107
|
+
if (!rest) return hasColon ? { bare: false, rules: [], malformed: 'a pragma with a colon needs at least one rule id' } : { bare: true, rules: [], malformed: null };
|
|
108
|
+
if (!hasColon && /^[A-Za-z0-9_.:/-]/.test(rest) && !/^[,\s]/.test(rest)) return { bare: false, rules: [], malformed: 'rule ids follow a colon: "agentic-security-ignore: <rule>"' };
|
|
109
|
+
const toks = rest.split(/[\s,]+/).filter(Boolean);
|
|
110
|
+
const bad = toks.find((t) => !RULE_ID.test(t));
|
|
111
|
+
if (bad) return { bare: false, rules: [], malformed: `"${bad}" is not a rule id` };
|
|
112
|
+
return { bare: false, rules: toks, malformed: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The pragma a source carries on `line`, or null. `file` selects the comment grammar (.hs/.lhs or .nix); any other
|
|
117
|
+
* file type returns null (the generic pragma handles those).
|
|
118
|
+
*/
|
|
119
|
+
export function languagePragmaOnLine(file, src, line) {
|
|
120
|
+
if (typeof src !== 'string' || !Number.isInteger(line) || line < 1) return null;
|
|
121
|
+
const isHs = /\.l?hs$|\.hs-boot$|\.hsc$/i.test(file || '');
|
|
122
|
+
const isNix = /\.nix$/i.test(file || '');
|
|
123
|
+
if (!isHs && !isNix) return null;
|
|
124
|
+
const comments = isHs ? haskellComments(src) : nixComments(src);
|
|
125
|
+
const starts = lineStarts(src);
|
|
126
|
+
const found = [];
|
|
127
|
+
for (const c of comments) {
|
|
128
|
+
if (!MARK.test(c.text)) continue;
|
|
129
|
+
// the line the words sit on (a block comment may span several lines)
|
|
130
|
+
const off = c.start + c.text.search(MARK);
|
|
131
|
+
if (lineOfOffset(starts, off) !== line) continue;
|
|
132
|
+
const p = parsePragma(commentBody(c.text));
|
|
133
|
+
if (p) found.push(p);
|
|
134
|
+
}
|
|
135
|
+
if (!found.length) return null;
|
|
136
|
+
const rules = []; let bare = false; let malformed = null;
|
|
137
|
+
for (const p of found) { if (p.bare) bare = true; rules.push(...p.rules); if (p.malformed) malformed = p.malformed; }
|
|
138
|
+
return { bare, rules, malformed, form: isHs ? 'haskell' : 'nix' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const slug = (s) => String(s || '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
142
|
+
|
|
143
|
+
/** Whether a parsed language pragma suppresses finding `f`: bare, or an EXACT match on one of its identifiers. */
|
|
144
|
+
export function languagePragmaSuppresses(p, f) {
|
|
145
|
+
if (!p || !f) return false;
|
|
146
|
+
if (p.malformed && !p.bare && !p.rules.length) return false;
|
|
147
|
+
if (p.bare) return true;
|
|
148
|
+
const ids = new Set([f.rule, f.id, f.family, f.cwe, f.ruleId, slug(f.vuln), typeof f.id === 'string' ? f.id.split(':')[0] : null].filter(Boolean).map((x) => String(x).toLowerCase()));
|
|
149
|
+
return p.rules.some((r) => ids.has(String(r).toLowerCase()));
|
|
150
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Resolved dependency data in the scan path (QA-005.AC04): explicit exports a user or tool wrote on purpose become part of a normal scan,
|
|
2
|
+
// instead of being reachable only from a unit test.
|
|
3
|
+
//
|
|
4
|
+
// Haskell dist-newstyle/cache/plan.json and .stack-work/dependencies.json -> a resolved graph (transitive packages, exact
|
|
5
|
+
// versions, scopes) joined to the declared manifests; stale data is used for nothing and disclosed.
|
|
6
|
+
// Nix nix-export.json (and .direnv/nix-export.json) -> an imported closure, matched against an advisory
|
|
7
|
+
// snapshot with upstream-identity and patch-evidence rules; no feed means "unknown", never "clean".
|
|
8
|
+
// Eval AGENTIC_SECURITY_NIX_EVAL=1 + AGENTIC_SECURITY_NIX_TARGET=<attr> -> an isolated, opt-in evaluation whose state is its
|
|
9
|
+
// own scan-health field. A scan that did not select it never runs a Nix evaluator.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here fetches, builds, evaluates or runs a project's code unless the evaluation is selected AND the sandbox proves its isolation.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { statePath } from '../posture/state-dir.js';
|
|
15
|
+
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
16
|
+
import { buildResolvedGraph } from './haskell-resolved-graph.js';
|
|
17
|
+
import { importNixClosure } from './nix-closure.js';
|
|
18
|
+
import { NixAdvisoryData, matchNixVulnerabilities, overlayEvidence } from './nix-sca.js';
|
|
19
|
+
import { configuredAdvisoryDb, collectUsage, manifestFiles } from './haskell-supply.js';
|
|
20
|
+
import { runIsolatedEval, mergeEvaluationHealth, _which } from './nix-eval-isolation.js';
|
|
21
|
+
|
|
22
|
+
export const RESOLVED_PASS_VERSION = 'resolved-pass/1';
|
|
23
|
+
|
|
24
|
+
const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
|
|
25
|
+
const STACK_EXPORT = /(?:^|\/)\.stack-work\/dependencies\.json$/;
|
|
26
|
+
const NIX_EXPORT = /(?:^|\/)nix-export\.json$/;
|
|
27
|
+
const ACTIONABLE = new Set(['affected', 'possibly-affected', 'candidate', 'backported-verified']);
|
|
28
|
+
|
|
29
|
+
const memo = new WeakMap();
|
|
30
|
+
const memoized = (files, key, make) => { let m = memo.get(files); if (!m) { m = new Map(); memo.set(files, m); } if (!m.has(key)) m.set(key, make()); return m.get(key); };
|
|
31
|
+
|
|
32
|
+
function parseJson(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (e) { return { ok: false, reason: String((e && e.message) || e).slice(0, 120) }; } }
|
|
33
|
+
|
|
34
|
+
/** The resolved Haskell graph for a file set, or null when no explicit export is present. Cached per file set. */
|
|
35
|
+
export function resolvedHaskellGraph(files) {
|
|
36
|
+
return memoized(files, 'hs-graph', () => {
|
|
37
|
+
const planFile = Object.keys(files).find((p) => PLAN.test(p));
|
|
38
|
+
const stackFile = !planFile ? Object.keys(files).find((p) => STACK_EXPORT.test(p)) : null;
|
|
39
|
+
const src = planFile || stackFile;
|
|
40
|
+
if (!src) return null;
|
|
41
|
+
const mf = manifestFiles(files);
|
|
42
|
+
const manifests = mf.length ? analyzeHaskellManifests(mf) : null;
|
|
43
|
+
const parsed = parseJson(files[src]);
|
|
44
|
+
if (!parsed.ok) return { file: src, graph: { source: planFile ? 'cabal-plan' : 'stack-export', graphAvailable: false, units: null, edges: null, freshness: { status: 'unverified', reasons: [] }, closure: { complete: false, reason: 'malformed_resolved_data' }, gaps: [{ kind: planFile ? 'malformed_plan' : 'malformed_stack_export', detail: `${src} is not valid JSON: ${parsed.reason}` }], declared: [] }, manifests };
|
|
45
|
+
const graph = buildResolvedGraph({ manifests, ...(planFile ? { plan: parsed.value } : { stackExport: parsed.value }), expected: {} });
|
|
46
|
+
return { file: src, graph, manifests };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Components for the resolved graph: every Hackage unit with an exact version, its scope and where it came from. Only a graph that is
|
|
52
|
+
* not stale contributes; a stale one is returned as a gap (never as components), and a partial one says it is partial.
|
|
53
|
+
*/
|
|
54
|
+
export function resolvedHackageComponents(files) {
|
|
55
|
+
const r = resolvedHaskellGraph(files);
|
|
56
|
+
if (!r) return { components: [], gaps: [], summary: null };
|
|
57
|
+
const g = r.graph;
|
|
58
|
+
const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
|
|
59
|
+
const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
|
|
60
|
+
if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
|
|
61
|
+
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
62
|
+
const direct = new Set((g.declared || []).map((d) => d.name));
|
|
63
|
+
const components = [];
|
|
64
|
+
for (const u of g.units) {
|
|
65
|
+
if (u.origin === 'local' || u.origin === 'compiler' || !u.name || !u.version) continue; // the project itself and boot libraries are not dependencies to match
|
|
66
|
+
const scopes = Array.isArray(u.scopes) && u.scopes.length ? u.scopes : ['runtime'];
|
|
67
|
+
const scope = scopes.includes('runtime') ? 'runtime' : scopes[0];
|
|
68
|
+
components.push({ ecosystem: 'hackage', name: u.name, version: u.version, declaredRange: null, resolution: 'plan', scope, engineScope: SCOPE[scope] || 'required', target: null, componentKind: u.componentKind || null, manifest: r.file, line: null, ghcComponent: false, direct: direct.has(u.name), unitId: u.id, origin: u.origin, versionSource: { file: r.file, line: null } });
|
|
69
|
+
}
|
|
70
|
+
return { components, gaps, summary };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Nix closure ──────────────────────────────────────────────────────────────────
|
|
74
|
+
function loadNixExports(files) {
|
|
75
|
+
const exports = []; const sources = []; const problems = []; let expected = {};
|
|
76
|
+
for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) {
|
|
77
|
+
const parsed = parseJson(files[p]);
|
|
78
|
+
sources.push(p);
|
|
79
|
+
if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: p, detail: parsed.reason }); continue; }
|
|
80
|
+
const j = parsed.value;
|
|
81
|
+
if (j && Array.isArray(j.exports)) { exports.push(...j.exports.filter((e) => e && typeof e === 'object')); if (j.expected && typeof j.expected === 'object') expected = { ...expected, ...j.expected }; }
|
|
82
|
+
else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
|
|
83
|
+
else exports.push({ text: files[p] });
|
|
84
|
+
}
|
|
85
|
+
return { exports, sources, problems, expected };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
|
|
89
|
+
export function nixClosureOf(files, { now = Date.now() } = {}) {
|
|
90
|
+
return memoized(files, 'nix-closure', () => {
|
|
91
|
+
const { exports, sources, problems, expected } = loadNixExports(files);
|
|
92
|
+
if (!sources.length) return null;
|
|
93
|
+
const closure = importNixClosure({ exports, expected, now });
|
|
94
|
+
const drvEnv = {};
|
|
95
|
+
for (const ex of exports) {
|
|
96
|
+
if (ex.schema !== 'nix-derivation-show-json') continue;
|
|
97
|
+
let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
|
|
98
|
+
for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
|
|
99
|
+
}
|
|
100
|
+
return { closure, drvEnv, sources, problems };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
|
|
105
|
+
export function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
|
|
106
|
+
const path = env.AGENTIC_SECURITY_NIX_ADVISORIES || (root && existsSync(statePath(root, 'nix-advisories.json')) ? statePath(root, 'nix-advisories.json') : null);
|
|
107
|
+
if (!path) return { data: null, reason: 'no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES or provide nix-advisories.json in the project state directory)' };
|
|
108
|
+
let snap; try { snap = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
|
|
109
|
+
const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
|
|
110
|
+
return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Closure statuses and findings, with every disclosure the import and the feed produced. */
|
|
114
|
+
export function analyzeNixClosure(files, opts = {}) {
|
|
115
|
+
return memoized(files, 'nix-analysis', () => _analyzeNixClosure(files, opts));
|
|
116
|
+
}
|
|
117
|
+
function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = Date.now() } = {}) {
|
|
118
|
+
const c = nixClosureOf(files, { now });
|
|
119
|
+
if (!c) return null;
|
|
120
|
+
const hackage = configuredAdvisoryDb(scanRoot, env).db;
|
|
121
|
+
const adv = configuredNixAdvisories(scanRoot, env, hackage);
|
|
122
|
+
let usage = null; try { usage = collectUsage(files); } catch { usage = null; }
|
|
123
|
+
let matched = null;
|
|
124
|
+
try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
125
|
+
const gaps = [];
|
|
126
|
+
for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
|
|
127
|
+
for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
|
|
128
|
+
if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
|
|
129
|
+
else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
|
|
130
|
+
const findings = ((matched && matched.findings) || []).filter((f) => ACTIONABLE.has(f.status)).map((f) => ({ ...f, file: c.sources[0], line: 1 }));
|
|
131
|
+
return { status: c.closure.status, claims: c.closure.claims, sources: c.sources, summary: matched ? matched.summary : null, feed: matched ? matched.feed : null, statuses: matched ? matched.statuses : [], findings, gaps, licenses: matched ? matched.licenses : null, closure: c.closure };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── isolated evaluation (opt in) ─────────────────────────────────────────────────
|
|
135
|
+
/** Runs the isolated evaluation when AGENTIC_SECURITY_NIX_EVAL=1; otherwise null. Never throws; a failure is a recorded state. */
|
|
136
|
+
export async function runSelectedNixEval(root, env = process.env, overrides = {}) {
|
|
137
|
+
if (env.AGENTIC_SECURITY_NIX_EVAL !== '1') return null;
|
|
138
|
+
const attribute = env.AGENTIC_SECURITY_NIX_TARGET || '';
|
|
139
|
+
const nixBin = overrides.nix || (_which ? _which('nix') : null);
|
|
140
|
+
if (!attribute) return { status: 'blocked', reason: 'no evaluation target: set AGENTIC_SECURITY_NIX_TARGET to a flake output attribute (for example nixosConfigurations.host.config.system.build.toplevel.drvPath)', projectCodeEvaluated: false };
|
|
141
|
+
if (!nixBin) return { status: 'unsupported', reason: 'the nix binary was not found on PATH; the project\'s Nix code was NOT evaluated', projectCodeEvaluated: false };
|
|
142
|
+
try { return await runIsolatedEval({ nix: typeof nixBin === 'string' ? { file: nixBin } : nixBin, root, attribute, system: env.AGENTIC_SECURITY_NIX_SYSTEM || null, ...overrides }); }
|
|
143
|
+
catch (e) { return { status: 'failed', reason: `the evaluation harness failed: ${String((e && e.message) || e).slice(0, 160)}`, projectCodeEvaluated: false }; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { mergeEvaluationHealth };
|