@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
package/src/posture/sbom-diff.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// module produces the diff on the next scan.
|
|
22
22
|
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
|
-
import { statePath, stateWritesEnabled } from './state-dir.js';
|
|
24
|
+
import { statePath, stateWritesEnabled, safeWriteState } from './state-dir.js';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
26
|
import * as crypto from 'node:crypto';
|
|
27
27
|
import { execFileSync } from 'node:child_process';
|
|
@@ -52,6 +52,9 @@ function _gitHead(scanRoot) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function _snapshotKey(component) {
|
|
55
|
+
// Language components carry an identity that survives a version change but keeps two resolved builds apart
|
|
56
|
+
// (a flag variant, a second store hash); everything else keeps the ecosystem:name key it always had.
|
|
57
|
+
if (component.identityKey) return component.identityKey;
|
|
55
58
|
return `${component.ecosystem || 'unknown'}:${component.name || ''}`;
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -65,7 +68,6 @@ export function persistSbom(scanRoot, components) {
|
|
|
65
68
|
// empty `sbom-history/` in the scanned tree — invisible to `git status`,
|
|
66
69
|
// because git does not track empty directories, and therefore exactly the
|
|
67
70
|
// kind of mutation that passes a clean-status check while still being one.
|
|
68
|
-
if (stateWritesEnabled()) { try { fs.mkdirSync(dir, { recursive: true }); } catch {} }
|
|
69
71
|
const sha = _gitHead(scanRoot) || crypto.createHash('sha256').update(JSON.stringify(components)).digest('hex').slice(0, 12);
|
|
70
72
|
const snap = {
|
|
71
73
|
sha, ts: new Date().toISOString(),
|
|
@@ -73,14 +75,13 @@ export function persistSbom(scanRoot, components) {
|
|
|
73
75
|
components: components.map(c => ({
|
|
74
76
|
ecosystem: c.ecosystem, name: c.name, version: c.version,
|
|
75
77
|
purl: c.purl, scope: c.scope, isUnpinned: !!c.isUnpinned,
|
|
76
|
-
|
|
78
|
+
...(c.identityKey ? { identityKey: c.identityKey, bomRef: c.bomRef || null } : {}),
|
|
79
|
+
sha256: c.sha256 || c.integrity || ((c.hashes || []).find((h) => h.alg === 'SHA-256') || {}).content || null,
|
|
77
80
|
})),
|
|
78
81
|
};
|
|
79
82
|
// NON_MUTATING_SCAN_PRD S1 — history is a feature, but not at the cost of
|
|
80
83
|
// mutating a tree the caller only asked us to read.
|
|
81
|
-
if (stateWritesEnabled()) {
|
|
82
|
-
try { fs.writeFileSync(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); } catch {}
|
|
83
|
-
}
|
|
84
|
+
if (stateWritesEnabled()) safeWriteState(path.join(dir, `${sha}.json`), JSON.stringify(snap, null, 2)); // refuses a symlinked state directory or file
|
|
84
85
|
return snap;
|
|
85
86
|
}
|
|
86
87
|
|
package/src/posture/sbom.js
CHANGED
|
@@ -60,27 +60,49 @@ function _documentUuid(seed) {
|
|
|
60
60
|
return isDeterministic() ? _stableUuidFrom(seed) : crypto.randomUUID();
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// The bom-ref a vulnerability points at: the component's own ref when it has one (language components do), else the
|
|
64
|
+
// derived one. A different resolved build of the same package is a different component and keeps its own ref.
|
|
65
|
+
function _vulnTargetRef(components, s) {
|
|
66
|
+
const hit = components.find(c => c.bomRef && c.name === s.name && c.version === s.version && (c.ecosystem || '') === (s.ecosystem || ''));
|
|
67
|
+
return hit ? hit.bomRef : _bomRef({ ecosystem: s.ecosystem, name: s.name, version: s.version });
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
export function toCycloneDX(scan, meta = {}) {
|
|
64
|
-
const components = scan.components || [];
|
|
71
|
+
const components = [...(scan.components || []), ...((scan.languageBom && scan.languageBom.components) || [])];
|
|
72
|
+
scan = scan.languageBom ? { ...scan, bomRoot: scan.languageBom.root || scan.bomRoot, bomDependencies: scan.languageBom.dependencies, bomCompositions: scan.languageBom.compositions, bomProperties: scan.languageBom.properties } : scan;
|
|
65
73
|
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
66
74
|
const serialNumber = `urn:uuid:${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
|
|
67
75
|
|
|
76
|
+
// Language components (Hackage, Nix) carry their own identity, scope, hashes and build provenance; every other
|
|
77
|
+
// component keeps exactly the shape it always had. A version is written only when one is known (a null or empty
|
|
78
|
+
// version is not a valid CycloneDX string), and nothing here invents a license.
|
|
79
|
+
const refOf = (c) => c.bomRef || _bomRef(c);
|
|
68
80
|
const cdxComponents = components.map(c => ({
|
|
69
|
-
type: 'library',
|
|
70
|
-
'bom-ref':
|
|
81
|
+
type: c.cdxType || 'library',
|
|
82
|
+
'bom-ref': refOf(c),
|
|
71
83
|
name: c.name,
|
|
72
|
-
version: c.version,
|
|
73
|
-
purl: _purl(c),
|
|
84
|
+
...(c.version ? { version: c.version } : (c.bomRef ? {} : { version: c.version })),
|
|
85
|
+
...(c.purl || !c.bomRef ? { purl: _purl(c) } : {}),
|
|
74
86
|
...(c.license ? { licenses: [{ license: { id: c.license } }] } : {}),
|
|
75
|
-
...(c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {}),
|
|
87
|
+
...(c.cdxScope ? { scope: c.cdxScope } : (c.scope ? { scope: c.scope === 'dev' ? 'optional' : 'required' } : {})),
|
|
88
|
+
...(Array.isArray(c.hashes) && c.hashes.length ? { hashes: c.hashes } : {}),
|
|
89
|
+
...(Array.isArray(c.externalReferences) && c.externalReferences.length ? { externalReferences: c.externalReferences } : {}),
|
|
90
|
+
...(Array.isArray(c.properties) && c.properties.length ? { properties: c.properties } : {}),
|
|
76
91
|
}));
|
|
92
|
+
const knownRefs = new Set([...cdxComponents.map(x => x['bom-ref']), ...(scan.bomRoot ? [scan.bomRoot['bom-ref']] : [])]);
|
|
93
|
+
const cdxDependencies = (scan.bomDependencies || [])
|
|
94
|
+
.filter(d => knownRefs.has(d.ref))
|
|
95
|
+
.map(d => ({ ref: d.ref, dependsOn: (d.dependsOn || []).filter(r => knownRefs.has(r)) }));
|
|
96
|
+
const cdxCompositions = (scan.bomCompositions || [])
|
|
97
|
+
.map(c => ({ aggregate: c.aggregate, dependencies: (c.dependencies || []).filter(r => knownRefs.has(r)) }))
|
|
98
|
+
.filter(c => c.dependencies.length);
|
|
77
99
|
|
|
78
100
|
const vulnerabilities = supplyChain.map((s, i) => ({
|
|
79
101
|
// The last-resort id was crypto.randomUUID(), which reintroduced
|
|
80
102
|
// per-run drift for any advisory carrying neither an osvId nor an
|
|
81
103
|
// advisory string. Index within the (already deterministically sorted)
|
|
82
104
|
// supplyChain array identifies it just as well and is reproducible.
|
|
83
|
-
'bom-ref': `${
|
|
105
|
+
'bom-ref': `${_vulnTargetRef(components, s)}#${s.osvId || s.advisory || `unidentified-${i}`}`,
|
|
84
106
|
id: s.osvId || (s.cveAliases || [])[0] || s.advisory,
|
|
85
107
|
source: { name: 'OSV.dev', url: `https://osv.dev/vulnerability/${s.osvId || ''}` },
|
|
86
108
|
references: (s.cveAliases || []).map(cve => ({ id: cve, source: { name: 'NVD' } })),
|
|
@@ -89,7 +111,7 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
89
111
|
...(s.cvssVector ? [{ vector: s.cvssVector, method: 'CVSSv3' }] : []),
|
|
90
112
|
],
|
|
91
113
|
description: s.description || s.advisory || '',
|
|
92
|
-
affects: [{ ref:
|
|
114
|
+
affects: [{ ref: _vulnTargetRef(components, s) }],
|
|
93
115
|
properties: [
|
|
94
116
|
...(s.epssScore != null ? [{ name: 'epss:score', value: String(s.epssScore) }] : []),
|
|
95
117
|
...(s.epssPercentile != null ? [{ name: 'epss:percentile', value: String(s.epssPercentile) }] : []),
|
|
@@ -105,34 +127,53 @@ export function toCycloneDX(scan, meta = {}) {
|
|
|
105
127
|
metadata: {
|
|
106
128
|
timestamp: meta.startedAt || new Date().toISOString(),
|
|
107
129
|
tools: [{ vendor: 'Clear Capabilities', name: 'agentic-security', version: meta.engineVersion || 'dev' }],
|
|
108
|
-
component: { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
130
|
+
component: scan.bomRoot ? { ...scan.bomRoot } : { type: 'application', name: 'scan-target', version: '1.0.0' },
|
|
131
|
+
...(Array.isArray(scan.bomProperties) && scan.bomProperties.length ? { properties: scan.bomProperties } : {}),
|
|
109
132
|
},
|
|
110
133
|
components: cdxComponents,
|
|
134
|
+
...(cdxDependencies.length ? { dependencies: cdxDependencies } : {}),
|
|
135
|
+
...(cdxCompositions.length ? { compositions: cdxCompositions } : {}),
|
|
111
136
|
...(vulnerabilities.length ? { vulnerabilities } : {}),
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
139
|
|
|
115
140
|
export function toSPDX(scan, meta = {}) {
|
|
116
|
-
const components = scan.components || [];
|
|
141
|
+
const components = [...(scan.components || []), ...((scan.languageBom && scan.languageBom.components) || [])];
|
|
142
|
+
scan = scan.languageBom ? { ...scan, bomRoot: scan.languageBom.root || scan.bomRoot, bomDependencies: scan.languageBom.dependencies } : scan;
|
|
117
143
|
const supplyChain = (scan.supplyChain || []).filter(s => s.type === 'vulnerable_dep');
|
|
118
144
|
const docNamespace = `https://agentic-security.local/spdx/${_documentUuid(JSON.stringify(components.map(_bomRef)))}`;
|
|
119
|
-
|
|
145
|
+
// SPDX 2.3 `created` is YYYY-MM-DDThh:mm:ssZ: fractional seconds are not part of the format.
|
|
146
|
+
const ts = (meta.startedAt || new Date().toISOString()).replace(/\.\d+(Z|[+-]\d{2}:\d{2})$/, (_m, z) => (z === 'Z' ? 'Z' : z));
|
|
120
147
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
versionInfo:
|
|
125
|
-
downloadLocation: 'NOASSERTION',
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
148
|
+
const spdxIdOfRef = new Map();
|
|
149
|
+
// The described application itself is a package, so dependency edges that start at it have an element to attach to.
|
|
150
|
+
const rootPackage = scan.bomRoot && scan.bomRoot['bom-ref'] ? [{
|
|
151
|
+
SPDXID: 'SPDXRef-Package-root', name: scan.bomRoot.name, ...(scan.bomRoot.version ? { versionInfo: scan.bomRoot.version } : {}),
|
|
152
|
+
downloadLocation: 'NOASSERTION', filesAnalyzed: false, licenseConcluded: 'NOASSERTION', licenseDeclared: 'NOASSERTION', copyrightText: 'NOASSERTION',
|
|
153
|
+
primaryPackagePurpose: 'APPLICATION', externalRefs: [],
|
|
154
|
+
}] : [];
|
|
155
|
+
if (rootPackage.length) spdxIdOfRef.set(scan.bomRoot['bom-ref'], 'SPDXRef-Package-root');
|
|
156
|
+
const packages = components.map((c, i) => {
|
|
157
|
+
const id = `SPDXRef-Package-${i}`;
|
|
158
|
+
if (c.bomRef) spdxIdOfRef.set(c.bomRef, id);
|
|
159
|
+
return {
|
|
160
|
+
SPDXID: id,
|
|
161
|
+
name: c.name,
|
|
162
|
+
...((c.version || !c.bomRef) ? { versionInfo: c.version } : {}),
|
|
163
|
+
downloadLocation: 'NOASSERTION',
|
|
164
|
+
filesAnalyzed: false,
|
|
165
|
+
...(Array.isArray(c.hashes) && c.hashes.length ? { checksums: c.hashes.map(h => ({ algorithm: String(h.alg).replace('-', ''), checksumValue: h.content })) } : {}),
|
|
166
|
+
licenseConcluded: c.license || 'NOASSERTION',
|
|
167
|
+
licenseDeclared: c.license || 'NOASSERTION',
|
|
168
|
+
copyrightText: 'NOASSERTION',
|
|
169
|
+
...(c.bomRef ? { primaryPackagePurpose: 'LIBRARY' } : {}),
|
|
170
|
+
externalRefs: [...((c.purl || !c.bomRef) ? [{
|
|
171
|
+
referenceCategory: 'PACKAGE-MANAGER',
|
|
172
|
+
referenceType: 'purl',
|
|
173
|
+
referenceLocator: _purl(c),
|
|
174
|
+
}] : [])],
|
|
175
|
+
};
|
|
176
|
+
});
|
|
136
177
|
|
|
137
178
|
// SPDX expresses CVEs as external refs on the package, not separate elements
|
|
138
179
|
const cveByName = {};
|
|
@@ -162,11 +203,23 @@ export function toSPDX(scan, meta = {}) {
|
|
|
162
203
|
created: ts,
|
|
163
204
|
creators: [`Tool: agentic-security-${meta.engineVersion || 'dev'}`],
|
|
164
205
|
},
|
|
165
|
-
packages,
|
|
166
|
-
relationships:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
206
|
+
packages: [...rootPackage, ...packages],
|
|
207
|
+
relationships: [
|
|
208
|
+
...[...rootPackage, ...packages].map(p => ({
|
|
209
|
+
spdxElementId: 'SPDXRef-DOCUMENT',
|
|
210
|
+
relatedSpdxElement: p.SPDXID,
|
|
211
|
+
relationshipType: 'DESCRIBES',
|
|
212
|
+
})),
|
|
213
|
+
// Dependency edges between language components. Runtime edges are DEPENDS_ON; a build- or test-only target is
|
|
214
|
+
// expressed with the SPDX 2.3 *_DEPENDENCY_OF relationship so the scope survives the conversion.
|
|
215
|
+
...(scan.bomDependencies || []).flatMap(d => (d.dependsOn || []).map(to => {
|
|
216
|
+
const a = spdxIdOfRef.get(d.ref); const b = spdxIdOfRef.get(to);
|
|
217
|
+
if (!a || !b) return null;
|
|
218
|
+
const kind = (d.kinds || {})[to] || 'runtime';
|
|
219
|
+
if (kind === 'test') return { spdxElementId: b, relatedSpdxElement: a, relationshipType: 'TEST_DEPENDENCY_OF' };
|
|
220
|
+
if (kind === 'build') return { spdxElementId: b, relatedSpdxElement: a, relationshipType: 'BUILD_DEPENDENCY_OF' };
|
|
221
|
+
return { spdxElementId: a, relatedSpdxElement: b, relationshipType: 'DEPENDS_ON' };
|
|
222
|
+
})).filter(Boolean),
|
|
223
|
+
],
|
|
171
224
|
};
|
|
172
225
|
}
|
|
@@ -41,6 +41,7 @@ import * as path from 'node:path';
|
|
|
41
41
|
import * as yaml from '../util/yaml.js';
|
|
42
42
|
|
|
43
43
|
import { safeWriteState, stateDir, statePath } from './state-dir.js';
|
|
44
|
+
import { BUILD_TRUST_TYPE } from '../language/contracts.js';
|
|
44
45
|
const DEFAULT_POLICY = {
|
|
45
46
|
acceptRisk: [],
|
|
46
47
|
sla: {},
|
|
@@ -75,6 +76,7 @@ function _normalizeAccept(entry) {
|
|
|
75
76
|
return {
|
|
76
77
|
cve: entry.cve ? String(entry.cve).toUpperCase() : null,
|
|
77
78
|
package: entry.package ? String(entry.package).toLowerCase() : null,
|
|
79
|
+
rule: entry.rule ? String(entry.rule) : null,
|
|
78
80
|
version: entry.version ? String(entry.version) : null,
|
|
79
81
|
ecosystem: entry.ecosystem ? String(entry.ecosystem).toLowerCase() : null,
|
|
80
82
|
reason: entry.reason || '',
|
|
@@ -125,6 +127,13 @@ export function matchAcceptRisk(finding, policy, today = new Date()) {
|
|
|
125
127
|
const pkgName = String(finding.name || '').toLowerCase();
|
|
126
128
|
for (const entry of policy.acceptRisk) {
|
|
127
129
|
if (!_accepted(entry, today)) continue;
|
|
130
|
+
// A `rule:` entry targets a build-trust policy finding (no CVE, no version) by its rule id.
|
|
131
|
+
if (entry.rule) {
|
|
132
|
+
if (finding.type === BUILD_TRUST_TYPE && finding.rule === entry.rule
|
|
133
|
+
&& (!entry.package || pkgName === entry.package)
|
|
134
|
+
&& (!entry.ecosystem || entry.ecosystem === finding.ecosystem)) return entry;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
128
137
|
if (entry.cve && cves.includes(entry.cve)) return entry;
|
|
129
138
|
if (entry.package && pkgName === entry.package) {
|
|
130
139
|
if (entry.version && finding.version && entry.version !== finding.version) continue;
|
|
@@ -144,7 +153,7 @@ export function applyScaPolicy(findings, policy, scanTime = new Date()) {
|
|
|
144
153
|
const stats = { suppressed: 0, slaTagged: 0, frozen: 0 };
|
|
145
154
|
if (!policy || !Array.isArray(findings)) return stats;
|
|
146
155
|
for (const f of findings) {
|
|
147
|
-
if (!f || f.type !== 'vulnerable_dep') continue;
|
|
156
|
+
if (!f || (f.type !== 'vulnerable_dep' && f.type !== BUILD_TRUST_TYPE)) continue;
|
|
148
157
|
// Accept-risk suppression
|
|
149
158
|
const acceptance = matchAcceptRisk(f, policy, scanTime);
|
|
150
159
|
if (acceptance) {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// Verdict enum (must match the agent + downstream /fix --sca):
|
|
14
14
|
// AUTO_MERGE_PATCH · WAIT_FOR_PATCH · MANUAL_REVIEW · ACCEPT_RISK · WONT_FIX
|
|
15
15
|
|
|
16
|
+
import { BUILD_TRUST_TYPE } from '../language/contracts.js';
|
|
17
|
+
|
|
16
18
|
export const SCA_VERDICTS = ['AUTO_MERGE_PATCH', 'WAIT_FOR_PATCH', 'MANUAL_REVIEW', 'ACCEPT_RISK', 'WONT_FIX'];
|
|
17
19
|
|
|
18
20
|
const REACHABLE_TIERS = new Set(['route-reachable-via-function', 'function-reachable', 'import-reachable']);
|
|
@@ -64,6 +66,11 @@ export function computeScaVerdict(sc, opts = {}) {
|
|
|
64
66
|
if (sc.suppressed || sc.suppressionReason) {
|
|
65
67
|
return { verdict: 'ACCEPT_RISK', reason: sc.suppressionReason || 'matched existing accept-risk in sca-policy.yml' };
|
|
66
68
|
}
|
|
69
|
+
// 1b. Nix build-trust policy findings have no advisory, fixed version or upgrade
|
|
70
|
+
// path: the remedy is a configuration or pin change, which is always a human call.
|
|
71
|
+
if (sc.type === BUILD_TRUST_TYPE) {
|
|
72
|
+
return { verdict: 'MANUAL_REVIEW', reason: `${sc.rule}: ${sc.remediation || 'review the Nix build-trust setting'}` };
|
|
73
|
+
}
|
|
67
74
|
// 2. No fixed version exists → nothing to upgrade yet.
|
|
68
75
|
if (!fixed.length) {
|
|
69
76
|
return { verdict: 'WAIT_FOR_PATCH', reason: `no fixed version published${sc.osvId ? ` for ${sc.osvId}` : ''}` };
|
|
@@ -116,7 +123,7 @@ export function annotateScaVerdicts(supplyChain, opts = {}) {
|
|
|
116
123
|
if (!Array.isArray(supplyChain)) return supplyChain;
|
|
117
124
|
const counts = { AUTO_MERGE_PATCH: 0, WAIT_FOR_PATCH: 0, MANUAL_REVIEW: 0, ACCEPT_RISK: 0, WONT_FIX: 0 };
|
|
118
125
|
for (const sc of supplyChain) {
|
|
119
|
-
if (!sc || sc.type !== 'vulnerable_dep') continue;
|
|
126
|
+
if (!sc || (sc.type !== 'vulnerable_dep' && sc.type !== BUILD_TRUST_TYPE)) continue;
|
|
120
127
|
const { verdict, reason, expiryDays } = computeScaVerdict(sc, opts);
|
|
121
128
|
sc.scaVerdict = verdict;
|
|
122
129
|
sc.scaVerdictReason = reason;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
15
|
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
16
|
+
import { providerInfo, scanLanguageSecretConcat, scanDependencyUrlCredentials } from '../language/secrets.js';
|
|
16
17
|
|
|
17
18
|
// Pull the post-image (added) lines out of a unified diff: lines starting with
|
|
18
19
|
// a single '+' (not the '+++' file header). Returns reconstructed text.
|
|
@@ -24,25 +25,42 @@ export function extractAddedLines(diffText) {
|
|
|
24
25
|
return out.join('\n');
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// Split a unified diff into per-file sections with the POST-IMAGE line number of every added line, so a
|
|
29
|
+
// finding can name the file and line it was committed at (the combined text alone loses both).
|
|
30
|
+
export function splitDiffByFile(diffText) {
|
|
31
|
+
const files = [];
|
|
32
|
+
let cur = null; let next = 0;
|
|
33
|
+
for (const line of String(diffText || '').split('\n')) {
|
|
34
|
+
const h = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
35
|
+
if (h) { cur = { path: h[2], added: [] }; files.push(cur); next = 0; continue; }
|
|
36
|
+
if (!cur) continue;
|
|
37
|
+
const hh = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
38
|
+
if (hh) { next = parseInt(hh[1], 10); continue; }
|
|
39
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
40
|
+
if (line.startsWith('+')) { cur.added.push({ text: line.slice(1), line: next }); next++; }
|
|
41
|
+
else if (!line.startsWith('-') && !line.startsWith('\\')) next++;
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
// Pure: run the injected credential detector over a commit's added lines.
|
|
28
|
-
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
|
|
29
|
-
|
|
47
|
+
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[]. `extra` are additional detectors
|
|
48
|
+
// ((path, text) => Finding[]) run against each file's added lines WITH the real path, so extension-gated
|
|
49
|
+
// detectors (Haskell/Nix split-secret) apply to history too.
|
|
50
|
+
export function scanHistoryDiff(diffText, commit, detectFn, extra = []) {
|
|
30
51
|
if (typeof detectFn !== 'function') return [];
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
...f,
|
|
52
|
+
const sections = splitDiffByFile(diffText);
|
|
53
|
+
const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
|
|
54
|
+
const wrap = (f, sec, addedLines) => {
|
|
55
|
+
const hit = Number.isInteger(f.line) && f.line >= 1 ? addedLines[f.line - 1] : null;
|
|
56
|
+
const prov = f.rotation ? {} : providerInfo(f._rawProviderValue || '');
|
|
57
|
+
const out = {
|
|
58
|
+
...f, ...prov,
|
|
39
59
|
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
40
|
-
file: `git-history@${commit}`,
|
|
41
|
-
line:
|
|
42
|
-
commit,
|
|
43
|
-
_historical: true,
|
|
60
|
+
file: `git-history@${commit}`, line: 0, commit, _historical: true,
|
|
61
|
+
...(sec ? { sourceFile: sec.path, sourceLine: hit ? hit.line : null } : {}),
|
|
44
62
|
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
45
|
-
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
63
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}${sec ? ` (${sec.path}${hit ? `:${hit.line}` : ''})` : ''}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
46
64
|
remediation,
|
|
47
65
|
// report/index.js's _remediationOf checks `.fix` before `.remediation`
|
|
48
66
|
// — the underlying detector already set `.fix` to a generic "remove
|
|
@@ -51,15 +69,41 @@ export function scanHistoryDiff(diffText, commit, detectFn) {
|
|
|
51
69
|
// insufficient") in every report format.
|
|
52
70
|
fix: remediation,
|
|
53
71
|
};
|
|
54
|
-
|
|
72
|
+
delete out._rawProviderValue;
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
const results = [];
|
|
76
|
+
if (!sections.length) {
|
|
77
|
+
const added = extractAddedLines(diffText);
|
|
78
|
+
if (!added.trim()) return [];
|
|
79
|
+
let findings = [];
|
|
80
|
+
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
81
|
+
return findings.map((f) => wrap(f, null, []));
|
|
82
|
+
}
|
|
83
|
+
for (const sec of sections) {
|
|
84
|
+
if (!sec.added.length) continue;
|
|
85
|
+
const text = sec.added.map((a) => a.text).join('\n');
|
|
86
|
+
if (!text.trim()) continue;
|
|
87
|
+
let findings = [];
|
|
88
|
+
try { findings = detectFn(`git-history@${commit}`, text) || []; } catch { findings = []; }
|
|
89
|
+
for (const f of findings) results.push(wrap(f, sec, sec.added));
|
|
90
|
+
for (const ex of extra || []) {
|
|
91
|
+
let more = [];
|
|
92
|
+
try { more = ex(sec.path, text) || []; } catch { more = []; }
|
|
93
|
+
for (const f of more) results.push(wrap(f, sec, sec.added));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
55
97
|
}
|
|
56
98
|
|
|
99
|
+
const defaultExtra = [scanLanguageSecretConcat, scanDependencyUrlCredentials];
|
|
100
|
+
|
|
57
101
|
/**
|
|
58
102
|
* Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
|
|
59
103
|
* [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
|
|
60
104
|
* that recurs across commits to its earliest sighting.
|
|
61
105
|
*/
|
|
62
|
-
export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
|
|
106
|
+
export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000, extraDetectors = null } = {}) {
|
|
63
107
|
if (!scanRoot || typeof detectFn !== 'function') return [];
|
|
64
108
|
let out;
|
|
65
109
|
try {
|
|
@@ -78,7 +122,7 @@ export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs
|
|
|
78
122
|
const seen = new Set();
|
|
79
123
|
for (let i = 1; i < parts.length; i += 2) {
|
|
80
124
|
const sha = (parts[i] || '').slice(0, 12);
|
|
81
|
-
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
|
|
125
|
+
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn, extraDetectors || defaultExtra)) {
|
|
82
126
|
const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
|
|
83
127
|
if (seen.has(key)) continue;
|
|
84
128
|
seen.add(key);
|
package/src/posture/stable-id.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// that need stability use `stableId`.
|
|
11
11
|
|
|
12
12
|
import * as crypto from 'node:crypto';
|
|
13
|
+
import { haskellComments, nixComments } from '../language/pragma.js';
|
|
13
14
|
|
|
14
15
|
function normalizeSnippet(s) {
|
|
15
16
|
if (!s || typeof s !== 'string') return '';
|
|
@@ -53,12 +54,30 @@ function ruleId(f) {
|
|
|
53
54
|
return `vuln:${(f.vuln || '').slice(0, 40)}`;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// Haskell and Nix findings are identified by their code, not by how it is laid out: comments, blank lines,
|
|
58
|
+
// indentation and spacing between tokens never rotate an id. (The generic normaliser keeps spacing next to
|
|
59
|
+
// punctuation, so `f (x)` and `f ( x )` would differ.) Only whitespace and comments are removed; every token stays.
|
|
60
|
+
function normalizeLanguageSnippet(file, s) {
|
|
61
|
+
if (!s || typeof s !== 'string') return '';
|
|
62
|
+
const isNix = /\.nix$/i.test(String(file || ''));
|
|
63
|
+
const spans = isNix ? nixComments(s) : haskellComments(s);
|
|
64
|
+
let out = ''; let at = 0;
|
|
65
|
+
for (const c of spans) { out += s.slice(at, c.start); at = c.end; }
|
|
66
|
+
out += s.slice(at);
|
|
67
|
+
return out.toLowerCase().replace(/"(?:[^"\\]|\\.)*"/g, '"_S_"').replace(/\s+/g, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
export function computeStableId(f) {
|
|
57
71
|
const rid = ruleId(f);
|
|
58
|
-
const
|
|
72
|
+
const isLang = /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(String(f.file || f.sink?.file || ''));
|
|
73
|
+
const sink = isLang
|
|
74
|
+
? normalizeLanguageSnippet(f.file || f.sink?.file, f.sink?.snippet || f.snippet || '').slice(0, 200)
|
|
75
|
+
: normalizeSnippet(f.sink?.snippet || f.snippet || '').slice(0, 200);
|
|
59
76
|
const shape = pathShape(f);
|
|
60
77
|
const fileHint = basenameLike(f.file || f.sink?.file);
|
|
61
|
-
|
|
78
|
+
// Haskell taint findings carry a site key (a fingerprint of the sink argument), so two sinks of one rule
|
|
79
|
+
// on one line keep distinct ids. Absent for every other finding, so their ids are unchanged.
|
|
80
|
+
const material = f.siteKey ? `${rid}|${sink}|${shape}|${fileHint}|${f.siteKey}` : `${rid}|${sink}|${shape}|${fileHint}`;
|
|
62
81
|
return crypto.createHash('sha256').update(material).digest('hex').slice(0, 16);
|
|
63
82
|
}
|
|
64
83
|
|
package/src/posture/state-dir.js
CHANGED
|
@@ -33,8 +33,11 @@ const PROJECT_MARKERS = [
|
|
|
33
33
|
'build.gradle',
|
|
34
34
|
'composer.json',
|
|
35
35
|
'Gemfile',
|
|
36
|
+
// Haskell and Nix projects: without these a Cabal, Stack or flake project never got a state directory at all.
|
|
37
|
+
'cabal.project', 'stack.yaml', 'package.yaml', 'flake.nix', 'configuration.nix', 'default.nix', 'shell.nix',
|
|
36
38
|
'.agentic-security',
|
|
37
39
|
];
|
|
40
|
+
const PROJECT_MARKER_SUFFIX = /\.cabal$/;
|
|
38
41
|
|
|
39
42
|
function _findProjectRoot(startDir) {
|
|
40
43
|
let dir = path.resolve(startDir);
|
|
@@ -81,8 +84,23 @@ export function statePath(scanRoot, ...parts) {
|
|
|
81
84
|
// Safety check: refuse to create .agentic-security/ unless the parent
|
|
82
85
|
// directory has at least one project marker. Prevents littering when
|
|
83
86
|
// resolution falls through to a non-project directory.
|
|
87
|
+
// A state directory that is a SYMLINK is never safe: a hostile repository could point `.agentic-security` at a directory elsewhere
|
|
88
|
+
// and have every evidence file written (or overwritten) through it. The state root, and any state directory below it, must be a real
|
|
89
|
+
// directory (or not exist yet); a symlinked file inside it is refused by safeWriteState below.
|
|
90
|
+
function _viaSymlink(dir) {
|
|
91
|
+
const segments = path.resolve(dir).split(path.sep);
|
|
92
|
+
const idx = segments.lastIndexOf(STATE_DIR_NAME);
|
|
93
|
+
if (idx < 0) return false;
|
|
94
|
+
for (let i = idx; i < segments.length; i++) {
|
|
95
|
+
const prefix = segments.slice(0, i + 1).join(path.sep) || path.sep;
|
|
96
|
+
try { if (fs.lstatSync(prefix).isSymbolicLink()) return true; } catch { return false; /* does not exist yet: nothing to follow */ }
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
export function isSafeStateDir(dir) {
|
|
85
102
|
if (!dir) return false;
|
|
103
|
+
if (_viaSymlink(dir)) return false;
|
|
86
104
|
const parent = path.dirname(dir);
|
|
87
105
|
for (const m of PROJECT_MARKERS) {
|
|
88
106
|
if (m === '.agentic-security') continue; // would be circular
|
|
@@ -90,6 +108,7 @@ export function isSafeStateDir(dir) {
|
|
|
90
108
|
if (fs.existsSync(path.join(parent, m))) return true;
|
|
91
109
|
} catch { /* ignore */ }
|
|
92
110
|
}
|
|
111
|
+
try { if (fs.readdirSync(parent).some((n) => PROJECT_MARKER_SUFFIX.test(n))) return true; } catch { /* ignore */ }
|
|
93
112
|
// A directory NESTED inside an already-valid state root is safe too.
|
|
94
113
|
//
|
|
95
114
|
// Without this the check only ever accepted `<project>/.agentic-security`
|
|
@@ -234,6 +253,8 @@ export function safeWriteState(filePath, content, { category } = {}) {
|
|
|
234
253
|
}
|
|
235
254
|
try {
|
|
236
255
|
fs.mkdirSync(dir, { recursive: true });
|
|
256
|
+
// never write THROUGH a symlink: an attacker-placed link at the file's own path would redirect the write elsewhere
|
|
257
|
+
try { if (fs.lstatSync(filePath).isSymbolicLink()) return false; } catch { /* not there yet */ }
|
|
237
258
|
fs.writeFileSync(filePath, content);
|
|
238
259
|
return true;
|
|
239
260
|
} catch {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// .agentic-security/threat-model.md (human-readable).
|
|
24
24
|
|
|
25
25
|
import * as fs from 'node:fs';
|
|
26
|
-
import { stateDir, stateWritesEnabled } from './state-dir.js';
|
|
26
|
+
import { stateDir, stateWritesEnabled, safeWriteState } from './state-dir.js';
|
|
27
27
|
import * as path from 'node:path';
|
|
28
28
|
|
|
29
29
|
// STRIDE category descriptors
|
|
@@ -215,9 +215,9 @@ export function persistThreatModel(scanRoot, model) {
|
|
|
215
215
|
// NON_MUTATING_SCAN_PRD S1 — a scan must not modify the tree it scans.
|
|
216
216
|
if (!stateWritesEnabled()) return;
|
|
217
217
|
const dir = stateDir(scanRoot);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
// through the state seam: a symlinked state directory or file is refused, never written through
|
|
219
|
+
safeWriteState(path.join(dir, 'threat-model.json'), JSON.stringify(model, null, 2));
|
|
220
|
+
safeWriteState(path.join(dir, 'threat-model.md'), renderMarkdown(model));
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
function renderMarkdown(model) {
|
package/src/posture/triage.js
CHANGED
|
@@ -29,13 +29,24 @@ function _save(scanRoot, data) {
|
|
|
29
29
|
|
|
30
30
|
// Sync the triage store with the latest scan: new findings become 'open',
|
|
31
31
|
// findings no longer surfaced become 'fixed' (with a transition entry).
|
|
32
|
-
export function syncWithScan(scanRoot, findings) {
|
|
32
|
+
export function syncWithScan(scanRoot, findings, opts = {}) {
|
|
33
33
|
const data = loadTriage(scanRoot);
|
|
34
34
|
const now = new Date().toISOString();
|
|
35
35
|
const seen = new Set();
|
|
36
|
+
// A finding that is no longer reported is only "fixed" when the scan that stopped reporting it was COMPLETE for the
|
|
37
|
+
// code it lives in. If its analysis was partial (a parser failed, a required analyzer was off), its absence is
|
|
38
|
+
// unverified, so it stays open and is marked `unverified` rather than silently closed.
|
|
39
|
+
const health = opts && opts.scanHealth;
|
|
40
|
+
const incomplete = !!(health && health.status && health.status !== 'complete');
|
|
36
41
|
for (const f of findings) {
|
|
37
42
|
const id = f.id || `${f.file}:${f.line}:${f.vuln}`;
|
|
38
43
|
seen.add(id);
|
|
44
|
+
const existing = data.findings[id];
|
|
45
|
+
// A finding that was closed and is reported again is a regression: reopen it, never leave it 'fixed'.
|
|
46
|
+
if (existing && existing.state === 'fixed') {
|
|
47
|
+
existing.state = 'open'; existing.reopened_at = now; delete existing.fixed_at; delete existing.unverified;
|
|
48
|
+
data.transitions.push({ id, from: 'fixed', to: 'open', at: now, automatic: true, reason: 'reported again by a later scan' });
|
|
49
|
+
} else if (existing && existing.unverified) { delete existing.unverified; }
|
|
39
50
|
if (!data.findings[id]) {
|
|
40
51
|
data.findings[id] = {
|
|
41
52
|
id,
|
|
@@ -56,6 +67,8 @@ export function syncWithScan(scanRoot, findings) {
|
|
|
56
67
|
ecosystem: f.ecosystem || null,
|
|
57
68
|
osvId: f.osvId || null,
|
|
58
69
|
cveAliases: Array.isArray(f.cveAliases) ? f.cveAliases : [],
|
|
70
|
+
language: f.language || null,
|
|
71
|
+
rule: f.rule || null,
|
|
59
72
|
};
|
|
60
73
|
data.transitions.push({ id, from: null, to: 'open', at: now });
|
|
61
74
|
}
|
|
@@ -65,6 +78,10 @@ export function syncWithScan(scanRoot, findings) {
|
|
|
65
78
|
const cur = data.findings[id];
|
|
66
79
|
if (seen.has(id)) continue;
|
|
67
80
|
if (cur.state === 'fixed' || cur.state === 'wont-fix' || cur.state === 'false-positive') continue;
|
|
81
|
+
if (incomplete) {
|
|
82
|
+
if (!cur.unverified) { cur.unverified = { since: now, reason: 'not reported, but the scan was not complete' }; data.transitions.push({ id, from: cur.state, to: cur.state, at: now, automatic: true, unverified: true, reason: 'not reported by an incomplete scan' }); }
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
68
85
|
cur.state = 'fixed';
|
|
69
86
|
cur.fixed_at = now;
|
|
70
87
|
data.transitions.push({ id, from: 'open', to: 'fixed', at: now, automatic: true });
|
|
@@ -24,6 +24,7 @@ import * as fsSync from 'node:fs';
|
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
|
|
26
26
|
import { stateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
27
|
+
import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, isExplicitExport, impactedFiles } from '../language/discovery.js';
|
|
27
28
|
const STATUS_MD = 'watch-status.md';
|
|
28
29
|
const STATUS_JSON = 'watch-status.json';
|
|
29
30
|
const DEBOUNCE_MS = 350;
|
|
@@ -33,10 +34,35 @@ const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp
|
|
|
33
34
|
const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
|
|
34
35
|
|
|
35
36
|
function _isScanable(rel) {
|
|
36
|
-
if (!rel
|
|
37
|
+
if (!rel) return false;
|
|
38
|
+
// An explicit export is evidence even though it lives in a build directory.
|
|
39
|
+
if (isExplicitExport(rel)) return true;
|
|
40
|
+
if (IGNORE_DIR_RE.test(rel)) return false;
|
|
41
|
+
if (isLanguageSource(rel) || isLanguageManifest(rel)) return !isLanguageExcludedPath(rel);
|
|
37
42
|
return SCAN_EXT_RE.test(rel);
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Widen a batch of changed absolute paths to every file whose results the
|
|
47
|
+
* change can stale: dependents through imports, and all Haskell/Nix sources for
|
|
48
|
+
* a manifest, lockfile or export change. Batches with no Haskell/Nix path are
|
|
49
|
+
* returned untouched, so other languages never pay for a tree read.
|
|
50
|
+
*/
|
|
51
|
+
async function expandChangedBatch(scanRoot, batch) {
|
|
52
|
+
const rels = batch.map((p) => path.relative(scanRoot, p).split(path.sep).join('/'));
|
|
53
|
+
if (!rels.some((r) => isLanguageSource(r) || isLanguageManifest(r) || isExplicitExport(r))) return batch;
|
|
54
|
+
try {
|
|
55
|
+
const { readTree } = await import('../runScan.js');
|
|
56
|
+
const { fileContents, depFileContents } = await readTree(scanRoot);
|
|
57
|
+
const hit = impactedFiles(fileContents, depFileContents, rels);
|
|
58
|
+
const out = new Set(batch);
|
|
59
|
+
for (const r of hit) out.add(path.join(scanRoot, r));
|
|
60
|
+
return [...out];
|
|
61
|
+
} catch {
|
|
62
|
+
return batch;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
function _readJsonSafe(fp) {
|
|
41
67
|
try { return JSON.parse(fsSync.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
42
68
|
}
|
|
@@ -144,7 +170,12 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
|
144
170
|
if (pending.size > MAX_BURST) { pending.clear(); return; }
|
|
145
171
|
const batch = Array.from(pending);
|
|
146
172
|
pending.clear();
|
|
147
|
-
|
|
173
|
+
const needsExpansion = batch.some((p) => {
|
|
174
|
+
const r = path.relative(scanRoot, p).split(path.sep).join('/');
|
|
175
|
+
return isLanguageSource(r) || isLanguageManifest(r) || isExplicitExport(r);
|
|
176
|
+
});
|
|
177
|
+
if (!needsExpansion) { try { onChange(batch); } catch {} return; }
|
|
178
|
+
expandChangedBatch(scanRoot, batch).then((b) => { try { onChange(b); } catch {} });
|
|
148
179
|
};
|
|
149
180
|
let stopped = false;
|
|
150
181
|
(async () => {
|
|
@@ -169,4 +200,4 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
|
169
200
|
};
|
|
170
201
|
}
|
|
171
202
|
|
|
172
|
-
export const _internals = { _isScanable, SCAN_EXT_RE, IGNORE_DIR_RE };
|
|
203
|
+
export const _internals = { _isScanable, expandChangedBatch, SCAN_EXT_RE, IGNORE_DIR_RE };
|