Skip to content

Commit 6d28344

Browse files
committed
Make zap reports work in cloud env
Introduce a transitional directory to copy zap reports thus changing user:group ownership of report files, directories and support files. We then recursivly update permissions in order to be able to traverse the nested directories and modify markup and styling. The orchestrator is thus able to traverse the report files and directories and also able to delete. The cloud instances periodically run a cron job to modify the user:group of the report files, directories and support files in order for us to attempt to delete immediately before we request zap generate reports.
1 parent 47c1894 commit 6d28344

File tree

3 files changed

+128
-23
lines changed

3 files changed

+128
-23
lines changed

Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,16 @@ RUN apk add --no-cache shadow && \
4343
# chmod 0440 /etc/sudoers.d/$USER
4444

4545
ENV WORKDIR /usr/src/app/
46+
ENV EMISSARY_OUTPUT_TRANSITION_DIR /usr/emissaryOutputTransition/
4647

4748
# Home is required for npm install. System account with no ability to login to shell
4849
# For standard node image:
4950
#RUN useradd --create-home --system --shell /bin/false $USER
5051
# For node alpine:
5152
# RUN addgroup -S $USER && adduser -S $USER -G $GROUP
5253

53-
RUN mkdir -p $WORKDIR && chown $USER:$GROUP --recursive $WORKDIR
54+
RUN mkdir -p $WORKDIR && chown $USER:$GROUP -R $WORKDIR \
55+
&& mkdir $EMISSARY_OUTPUT_TRANSITION_DIR && chown $USER:$GROUP -R $EMISSARY_OUTPUT_TRANSITION_DIR && chmod -R 770 $EMISSARY_OUTPUT_TRANSITION_DIR
5456

5557
#RUN cat /etc/resolv.conf
5658
#RUN echo "" > /etc/resolv.conf
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Used and adapted from: https://github.com/isaacs/chmodr
2+
/* eslint-disable */
3+
4+
const fs = require('fs');
5+
const path = require('path');
6+
7+
// If a party has r, add x
8+
// so that dirs are listable
9+
const dirMode = mode => {
10+
if (mode & 0o400)
11+
mode |= 0o100;
12+
if (mode & 0o40)
13+
mode |= 0o10;
14+
if (mode & 0o4)
15+
mode |= 0o1;
16+
return mode;
17+
}
18+
19+
const chmodrKid = (p, child, mode, cb) => {
20+
if (typeof child === 'string')
21+
return fs.lstat(path.resolve(p, child), (er, stats) => {
22+
if (er)
23+
return cb(er);
24+
stats.name = child;
25+
chmodrKid(p, stats, mode, cb);
26+
});
27+
28+
if (child.isDirectory()) {
29+
chmodr(path.resolve(p, child.name), mode, (er) => {
30+
if (er)
31+
return cb(er);
32+
fs.chmod(path.resolve(p, child.name), dirMode(mode), cb);
33+
})
34+
} else
35+
fs.chmod(path.resolve(p, child.name), mode, cb);
36+
};
37+
38+
const chmodr = async (p, mode, callbackFromChmodRKid) => {
39+
return new Promise((resolve, reject) => {
40+
fs.readdir(p, { withFileTypes: true }, (er, children) => {
41+
// any error other than ENOTDIR means it's not readable, or
42+
// doesn't exist. give up.
43+
if (er && er.code !== 'ENOTDIR') {
44+
return callbackFromChmodRKid ? callbackFromChmodRKid(er) : reject(er);
45+
}
46+
if (er) return fs.chmod(
47+
p,
48+
mode,
49+
callbackFromChmodRKid || ((err) => err ? reject(err) : resolve())
50+
);
51+
if (!children.length) return fs.chmod(p, mode, callbackFromChmodRKid || ((err) => err ? reject(err) : resolve()));
52+
53+
let len = children.length
54+
let errState = null
55+
const then = (er) => {
56+
if (errState) return;
57+
if (er) return callbackFromChmodRKid ? callbackFromChmodRKid(errState = er) : reject(errState = er);
58+
if (-- len === 0) return fs.chmod(p, dirMode(mode), callbackFromChmodRKid || ((err) => err ? reject(err) : resolve()))
59+
};
60+
61+
children.forEach(child => chmodrKid(p, child, mode, then))
62+
})
63+
});
64+
65+
};
66+
67+
module.exports = chmodr;
68+
/* eslint-enable */

src/sUtAndEmissaryStrategies/8_reporting/standard.js

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
const { promises: fsPromises } = require('fs');
1212
const Reporting = require('./strategy');
13+
const chmodr = require('./helper/chmodr');
1314

1415
const strings = require(`${process.cwd()}/src/strings`); // eslint-disable-line import/no-dynamic-require
1516
const config = require(`${process.cwd()}/config/config`); // eslint-disable-line import/no-dynamic-require
@@ -21,6 +22,7 @@ class Standard extends Reporting {
2122
#emissaryPropertiesSubSet
2223
#fileName = 'standard';
2324
#reportPrefix = 'report_';
25+
#emissaryOutputTransitionDir = '/usr/emissaryOutputTransition/'; // Defined in Dockerfile
2426

2527
constructor({ log, baseUrl, publisher, sutPropertiesSubSet, emissaryPropertiesSubSet, zAp }) {
2628
super({ log, publisher, zAp });
@@ -29,25 +31,34 @@ class Standard extends Reporting {
2931
this.#emissaryPropertiesSubSet = emissaryPropertiesSubSet;
3032
}
3133

32-
async #deleteLeftoverReportsAndSupportDirsIfExistFromPreviousTestRun() {
34+
async #deleteLeftoverReportsAndSupportDirsIfExistFromPreviousTestRuns() {
35+
const methodName = '#deleteLeftoverReportsAndSupportDirsIfExistFromPreviousTestRuns';
3336
const { dir: appTesterUploadDir } = config.get('upload');
3437
const { testSession: { id: testSessionId } } = this.#sutPropertiesSubSet;
3538
const fileAndDirNames = await fsPromises.readdir(appTesterUploadDir);
3639
const reportFileAndDirNames = fileAndDirNames.filter((f) => f.startsWith(`${this.#reportPrefix}appScannerId-${testSessionId}_`)); // Only delete what we are responsible for.
37-
await Promise.all(reportFileAndDirNames.map(async (r) => fsPromises.rm(`${appTesterUploadDir}${r}`, { recursive: true })));
40+
// Cron job defined in userData.tpl sets ownership on everything in this dir so that this process running as user app_scanner is able to delete old files.
41+
await Promise.all(reportFileAndDirNames.map(async (r) => fsPromises.rm(`${appTesterUploadDir}${r}`, { recursive: true })))
42+
.then(() => {
43+
const adminSuccessText = `Attempt to delete TestSession specific ("${testSessionId}") files and dirs from App Tester upload directory: "${appTesterUploadDir}" ✔ succeeded ✔.`;
44+
this.log.info(adminSuccessText, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });
45+
})
46+
.catch((err) => {
47+
const adminErrorText = `Attempt to delete TestSession specific ("${testSessionId}") files and dirs from App Tester upload directory: "${appTesterUploadDir}" ✖ failed ✖. This is probably because the machine instance's cron job to set write permissions has not yet run for these files, Error was: ${err.message}`;
48+
this.log.notice(adminErrorText, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });
49+
});
3850
}
3951

4052
async #applyMarkupReplacements(reportMetaData) {
4153
const methodName = '#applyMarkupReplacements';
4254
const { testSession: { id: testSessionId } } = this.#sutPropertiesSubSet;
43-
const { dir: appTesterUploadDir } = config.get('upload');
4455

4556
const reportMetaDataWithMarkupReplacements = reportMetaData.filter((r) => r.styling?.markupReplacements?.length);
4657

4758
// Read
4859
await Promise.all(reportMetaDataWithMarkupReplacements.map(async (rMWMR) => {
4960
const r = rMWMR;
50-
r.inputFileContent = await fsPromises.readFile(`${appTesterUploadDir}${r.generation.reportFileName}`, { encoding: 'utf8' })
61+
r.inputFileContent = await fsPromises.readFile(`${this.#emissaryOutputTransitionDir}${r.generation.reportFileName}`, { encoding: 'utf8' })
5162
.catch((err) => {
5263
const buildUserErrorText = `Error occurred while attempting to read report: "${r.generation.reportFileName}" in order to apply styling`;
5364
const adminErrorText = `${buildUserErrorText}, for Test Session with id: "${testSessionId}", Error was: ${err.message}`;
@@ -74,7 +85,7 @@ class Standard extends Reporting {
7485
return r;
7586
});
7687
// Write
77-
await Promise.all(reportMetadataWithOutputFileContent.map(async (r) => fsPromises.writeFile(`${appTesterUploadDir}${r.generation.reportFileName}`, r.outputFileContent)
88+
await Promise.all(reportMetadataWithOutputFileContent.map(async (r) => fsPromises.writeFile(`${this.#emissaryOutputTransitionDir}${r.generation.reportFileName}`, r.outputFileContent, { mode: 0o664 })
7889
.catch((err) => {
7990
const buildUserErrorText = `Error occurred while attempting to write report: "${r.generation.reportFileName}" in order to apply styling`;
8091
const adminErrorText = `${buildUserErrorText}, for Test Session with id: "${testSessionId}", Error was: ${err.message}`;
@@ -87,14 +98,13 @@ class Standard extends Reporting {
8798
async #applyStylingFileReplacements(reportMetaData) {
8899
const methodName = '#applyStylingFileReplacements';
89100
const { testSession: { id: testSessionId } } = this.#sutPropertiesSubSet;
90-
const { dir: appTesterUploadDir } = config.get('upload');
91101

92102
const reportMetaDataWithFileReplacements = reportMetaData.filter((r) => r.styling?.fileReplacements?.length);
93103

94104
await Promise.all(reportMetaDataWithFileReplacements.map(async (r) => Promise.all(r.styling.fileReplacements.map(async (fR) =>
95-
fsPromises.writeFile(`${appTesterUploadDir}${r.supportDir}/${fR.file}`, fR.content) // eslint-disable-line implicit-arrow-linebreak
105+
fsPromises.writeFile(`${this.#emissaryOutputTransitionDir}${r.supportDir}/${fR.file}`, fR.content, { mode: 0o664 }) // eslint-disable-line implicit-arrow-linebreak
96106
.catch((err) => {
97-
const buildUserErrorText = `Error occurred while attempting to write styling file replacement: "${r.supportDir}${fR.file}"`;
107+
const buildUserErrorText = `Error occurred while attempting to write styling file replacement: "${r.supportDir}/${fR.file}"`;
98108
const adminErrorText = `${buildUserErrorText}, for Test Session with id: "${testSessionId}", Error was: ${err.message}`;
99109
this.publisher.publish({ testSessionId, textData: `${buildUserErrorText}.`, tagObj: { tags: [`pid-${process.pid}`, this.#fileName, methodName] } });
100110
this.log.error(adminErrorText, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });
@@ -107,12 +117,17 @@ class Standard extends Reporting {
107117
await this.#applyStylingFileReplacements(reportMetaData);
108118
}
109119

110-
// emissaryUploadDir is emissary.upload.dir in config which is at time of writing /mnt/purpleteam-app-scanner/
111-
// appTesterUploadDir is upload.dir in config which is at time of writing /mnt/purpleteam-app-scanner/
112-
// reportDir is emissary.report.dr in config which is at time of writing /var/log/purpleteam/outcomes/
120+
// emissaryUploadDir is emissary.upload.dir in config which at time of writing is: /mnt/purpleteam-app-scanner/
121+
// appTesterUploadDir is upload.dir in config which at time of writing is: /mnt/purpleteam-app-scanner/
122+
// #emissaryOutputTransitionDir defined in this class which at time of writing is: /usr/emissaryOutputTransition/
123+
// reportDir is emissary.report.dr in config which at time of writing is: /var/log/purpleteam/outcomes/
113124

114-
// Zap saves reports to emissaryUploadDir
115-
// App Tester moves (cp, rm) move reports from appTesterUploadDir to reportDir
125+
// 1. App Tester attempts to delete previous reports of same TestSession from appTesterUploadDir
126+
// 2. Zap saves reports to emissaryUploadDir
127+
// 3. App Tester copies reports from appTesterUploadDir to #emissaryOutputTransitionDir
128+
// 4. App Tester changes permissions on files/dirs in #emissaryOutputTransitionDir
129+
// 5. App Tester applies markup and style changes
130+
// 6. App Tester copies reports from #emissaryOutputTransitionDir to reportDir, then deletes same reports from #emissaryOutputTransitionDir
116131
async createReports() {
117132
const methodName = 'createReports';
118133
const {
@@ -272,12 +287,12 @@ class Standard extends Reporting {
272287
sections: '', // All
273288
includedConfidences: 'Low|Medium|High|Confirmed',
274289
includedRisks: 'Informational|Low|Medium|High',
275-
reportFileName: `${this.#reportPrefix}appScannerId-${testSessionId}_risk-confidence_${nowAsFileName}.html`,
290+
reportFileName: `${this.#reportPrefix}appScannerId-${testSessionId}_risk-confidence-dark_${nowAsFileName}.html`,
276291
reportFileNamePattern: '',
277292
reportDir: emissaryUploadDir,
278293
display: false
279294
},
280-
supportDir: `${this.#reportPrefix}appScannerId-${testSessionId}_risk-confidence_${nowAsFileName}`,
295+
supportDir: `${this.#reportPrefix}appScannerId-${testSessionId}_risk-confidence-dark_${nowAsFileName}`,
281296
// Styling colours copied from https://purpleteam-labs.com/pricing/
282297
styling: {
283298
markupReplacements: [{
@@ -892,7 +907,7 @@ class Standard extends Reporting {
892907

893908
this.publisher.pubLog({ testSessionId, logLevel: 'info', textData: `The ${methodName}() method of the ${super.constructor.name} strategy "${this.constructor.name}" has been invoked.`, tagObj: { tags: [`pid-${process.pid}`, this.#fileName, methodName] } });
894909

895-
await this.#deleteLeftoverReportsAndSupportDirsIfExistFromPreviousTestRun();
910+
await this.#deleteLeftoverReportsAndSupportDirsIfExistFromPreviousTestRuns();
896911

897912
const { reports } = { ...testSessionAttributes };
898913
// If reports, then Build User decided to specify a sub-set of report types rather than all report types.
@@ -915,22 +930,42 @@ class Standard extends Reporting {
915930
});
916931
}, {});
917932

933+
const reportFileAndDirNames = [...chosenReportMetaData.map((r) => r.generation.reportFileName), ...chosenReportMetaData.filter((r) => r.supportDir).map((r) => r.supportDir)];
934+
935+
await Promise.all(reportFileAndDirNames.map(async (r) => fsPromises.cp(`${appTesterUploadDir}${r}`, `${this.#emissaryOutputTransitionDir}${r}`, { preserveTimestamps: true, recursive: true }))) // cp is experimental in node v17.
936+
.catch((err) => {
937+
const buildUserErrorText = `Error occurred while attempting to copy reports: "${reportFileAndDirNames}" from App Tester upload directory: "${appTesterUploadDir}" to Emissary output transition directory: "${this.#emissaryOutputTransitionDir}"`;
938+
const adminErrorText = `${buildUserErrorText}, for Test Session with id: "${testSessionId}", Error was: ${err.message}`;
939+
this.publisher.publish({ testSessionId, textData: `${buildUserErrorText}.`, tagObj: { tags: [`pid-${process.pid}`, this.#fileName, methodName] } });
940+
this.log.error(adminErrorText, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });
941+
});
942+
943+
await chmodr(this.#emissaryOutputTransitionDir, 0o664)
944+
.then(() => {
945+
this.log.info(`chmodr was successfully applied to Emissary output transition directory: ${this.#emissaryOutputTransitionDir}`, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });
946+
})
947+
.catch((err) => {
948+
const buildUserErrorText = 'Error occurred while attempting to execute chmod -R on the Emissary output transition directory';
949+
const adminErrorText = `${buildUserErrorText}, for Test Session with id: "${testSessionId}", Error was: ${err}`;
950+
this.publisher.publish({ testSessionId, textData: `${buildUserErrorText}.`, tagObj: { tags: [`pid-${process.pid}`, this.#fileName, methodName] } });
951+
this.log.error(adminErrorText, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });
952+
throw new Error(buildUserErrorText);
953+
});
954+
918955
// In order to compare the un-altered Zap reports with the PurpleTeam changes, comment this line out.
919956
await this.#applyReportStyling(chosenReportMetaData);
920957

921-
const reportFileAndDirNames = [...chosenReportMetaData.map((r) => r.generation.reportFileName), ...chosenReportMetaData.filter((r) => r.supportDir).map((r) => r.supportDir)];
922-
923-
await Promise.all(reportFileAndDirNames.map(async (r) => fsPromises.cp(`${appTesterUploadDir}${r}`, `${reportDir}${r}`, { preserveTimestamps: true, recursive: true }))) // cp is experimental in node v17.
958+
await Promise.all(reportFileAndDirNames.map(async (r) => fsPromises.cp(`${this.#emissaryOutputTransitionDir}${r}`, `${reportDir}${r}`, { preserveTimestamps: true, recursive: true }))) // cp is experimental in node v17.
924959
.catch((err) => {
925-
const buildUserErrorText = `Error occurred while attempting to copy reports: "${reportFileAndDirNames}" from App Tester upload directory: "${appTesterUploadDir}" to report directory: "${reportDir}"`;
960+
const buildUserErrorText = `Error occurred while attempting to copy reports: "${reportFileAndDirNames}" from Emissary output transition directory: "${this.#emissaryOutputTransitionDir}" to report directory: "${reportDir}"`;
926961
const adminErrorText = `${buildUserErrorText}, for Test Session with id: "${testSessionId}", Error was: ${err.message}`;
927962
this.publisher.publish({ testSessionId, textData: `${buildUserErrorText}.`, tagObj: { tags: [`pid-${process.pid}`, this.#fileName, methodName] } });
928963
this.log.error(adminErrorText, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });
929964
});
930965

931-
await Promise.all(reportFileAndDirNames.map(async (r) => fsPromises.rm(`${appTesterUploadDir}${r}`, { recursive: true })))
966+
await Promise.all(reportFileAndDirNames.map(async (r) => fsPromises.rm(`${this.#emissaryOutputTransitionDir}${r}`, { recursive: true })))
932967
.catch((err) => {
933-
const buildUserErrorText = `Error occurred while attempting to remove reports: "${reportFileAndDirNames}" from App Tester upload directory: "${appTesterUploadDir}"`;
968+
const buildUserErrorText = `Error occurred while attempting to remove reports: "${reportFileAndDirNames}" from Emissary output transition directory: "${this.#emissaryOutputTransitionDir}"`;
934969
const adminErrorText = `${buildUserErrorText}, for Test Session with id: "${testSessionId}", Error was: ${err.message}`;
935970
this.publisher.publish({ testSessionId, textData: `${buildUserErrorText}.`, tagObj: { tags: [`pid-${process.pid}`, this.#fileName, methodName] } });
936971
this.log.error(adminErrorText, { tags: [`pid-${process.pid}`, this.#fileName, methodName] });

0 commit comments

Comments
 (0)